Changes done in user-auth and template
This commit is contained in:
parent
5ffce5392a
commit
722f878243
231
AUTH_IMPLEMENTATION.md
Normal file
231
AUTH_IMPLEMENTATION.md
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
# Authentication Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the authentication system implementation with proper error handling, separate signup/signin routes, and email verification flow.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (User Auth Service)
|
||||||
|
- **Port**: 8011
|
||||||
|
- **Base URL**: `http://localhost:8011`
|
||||||
|
- **Database**: PostgreSQL
|
||||||
|
- **Features**: JWT authentication, email verification, session management
|
||||||
|
|
||||||
|
### Frontend (Next.js App)
|
||||||
|
- **Port**: 3001
|
||||||
|
- **Base URL**: `http://localhost:3001`
|
||||||
|
- **Framework**: Next.js 15.4.6 with TypeScript
|
||||||
|
- **UI**: Tailwind CSS + shadcn/ui components
|
||||||
|
|
||||||
|
## Routes Structure
|
||||||
|
|
||||||
|
### Frontend Routes
|
||||||
|
```
|
||||||
|
/signup - User registration page
|
||||||
|
/signin - User login page
|
||||||
|
/auth - Redirects to /signin (legacy support)
|
||||||
|
/verify-email - Email verification page (handled by backend redirect)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend API Endpoints
|
||||||
|
```
|
||||||
|
POST /api/auth/register - User registration
|
||||||
|
POST /api/auth/login - User login
|
||||||
|
GET /api/auth/verify-email - Email verification (redirects to frontend)
|
||||||
|
POST /api/auth/logout - User logout
|
||||||
|
POST /api/auth/refresh - Token refresh
|
||||||
|
GET /api/auth/me - Get user profile
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
### 1. Registration Flow
|
||||||
|
1. User visits `/signup`
|
||||||
|
2. Fills out registration form
|
||||||
|
3. Submits form → `POST /api/auth/register`
|
||||||
|
4. Backend creates user account and sends verification email
|
||||||
|
5. Frontend shows success message and redirects to `/signin` after 3 seconds
|
||||||
|
6. User receives email with verification link
|
||||||
|
|
||||||
|
### 2. Email Verification Flow
|
||||||
|
1. User clicks verification link in email
|
||||||
|
2. Link points to: `http://localhost:8011/api/auth/verify-email?token=<token>`
|
||||||
|
3. Backend verifies token and redirects to: `http://localhost:3001/signin?verified=true`
|
||||||
|
4. Frontend displays success message: "Email verified successfully! You can now sign in to your account."
|
||||||
|
|
||||||
|
### 3. Login Flow
|
||||||
|
1. User visits `/signin`
|
||||||
|
2. Fills out login form
|
||||||
|
3. Submits form → `POST /api/auth/login`
|
||||||
|
4. Backend validates credentials and returns JWT tokens
|
||||||
|
5. Frontend stores tokens and redirects to dashboard (`/`)
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Backend Error Responses
|
||||||
|
All API endpoints return consistent error format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Error type",
|
||||||
|
"message": "Detailed error message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Error Handling
|
||||||
|
- **Network Errors**: Display user-friendly messages
|
||||||
|
- **API Errors**: Show specific error messages from backend
|
||||||
|
- **Validation Errors**: Client-side validation with immediate feedback
|
||||||
|
- **Authentication Errors**: Clear messaging for login/registration issues
|
||||||
|
|
||||||
|
### Common Error Scenarios
|
||||||
|
|
||||||
|
#### Registration Errors
|
||||||
|
- **Email already exists**: "An account with this email already exists"
|
||||||
|
- **Username taken**: "Username is already taken"
|
||||||
|
- **Invalid email format**: "Please enter a valid email address"
|
||||||
|
- **Weak password**: "Password must be at least 8 characters long"
|
||||||
|
- **Missing fields**: "Please fill in all required fields"
|
||||||
|
|
||||||
|
#### Login Errors
|
||||||
|
- **Invalid credentials**: "Invalid email or password"
|
||||||
|
- **Email not verified**: "Please verify your email before signing in"
|
||||||
|
- **Account locked**: "Account is temporarily locked due to multiple failed attempts"
|
||||||
|
|
||||||
|
#### Email Verification Errors
|
||||||
|
- **Invalid token**: "Verification link is invalid or has expired"
|
||||||
|
- **Already verified**: "Email is already verified"
|
||||||
|
- **Token expired**: "Verification link has expired. Please request a new one"
|
||||||
|
|
||||||
|
## Components Structure
|
||||||
|
|
||||||
|
### Signup Flow
|
||||||
|
```
|
||||||
|
/signup
|
||||||
|
├── SignUpPage (main container)
|
||||||
|
├── SignUpForm (form component)
|
||||||
|
└── Success State (after registration)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signin Flow
|
||||||
|
```
|
||||||
|
/signin
|
||||||
|
├── SignInPage (main container)
|
||||||
|
├── SignInForm (form component)
|
||||||
|
└── Verification Messages (from URL params)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
### Authentication Handler (`authenticationHandler.tsx`)
|
||||||
|
- Handles API calls to backend
|
||||||
|
- Proper error propagation
|
||||||
|
- TypeScript interfaces for type safety
|
||||||
|
|
||||||
|
### Key Functions
|
||||||
|
```typescript
|
||||||
|
registerUser(data: RegisterData): Promise<ApiResponse>
|
||||||
|
loginUser(email: string, password: string): Promise<ApiResponse>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
### Backend Security
|
||||||
|
- JWT token authentication
|
||||||
|
- Password hashing (bcrypt)
|
||||||
|
- Rate limiting on auth endpoints
|
||||||
|
- CORS configuration
|
||||||
|
- Helmet security headers
|
||||||
|
- Session management
|
||||||
|
|
||||||
|
### Frontend Security
|
||||||
|
- Token storage in localStorage
|
||||||
|
- Automatic token refresh
|
||||||
|
- Secure API communication
|
||||||
|
- Input validation and sanitization
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
```env
|
||||||
|
PORT=8011
|
||||||
|
FRONTEND_URL=http://localhost:3001
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_DB=user_auth
|
||||||
|
JWT_SECRET=your-secret-key
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (.env.local)
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8011
|
||||||
|
NEXT_PUBLIC_FRONTEND_URL=http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing the Implementation
|
||||||
|
|
||||||
|
### 1. Start Services
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd automated-dev-pipeline
|
||||||
|
docker-compose up user-auth
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd codenuk-frontend-dark-theme
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test Registration
|
||||||
|
1. Visit `http://localhost:3001/signup`
|
||||||
|
2. Fill out registration form
|
||||||
|
3. Submit and check for success message
|
||||||
|
4. Check email for verification link
|
||||||
|
|
||||||
|
### 3. Test Email Verification
|
||||||
|
1. Click verification link in email
|
||||||
|
2. Should redirect to `http://localhost:3001/signin?verified=true`
|
||||||
|
3. Verify success message appears
|
||||||
|
|
||||||
|
### 4. Test Login
|
||||||
|
1. Visit `http://localhost:3001/signin`
|
||||||
|
2. Enter credentials
|
||||||
|
3. Should redirect to dashboard on success
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **CORS Errors**
|
||||||
|
- Check backend CORS configuration
|
||||||
|
- Verify frontend URL in allowed origins
|
||||||
|
|
||||||
|
2. **Email Not Sending**
|
||||||
|
- Check SMTP configuration in backend
|
||||||
|
- Verify email credentials
|
||||||
|
|
||||||
|
3. **Verification Link Not Working**
|
||||||
|
- Check frontend URL in backend configuration
|
||||||
|
- Verify token expiration settings
|
||||||
|
|
||||||
|
4. **Login Fails After Verification**
|
||||||
|
- Check if user is properly verified in database
|
||||||
|
- Verify JWT token generation
|
||||||
|
|
||||||
|
### Debug Steps
|
||||||
|
1. Check browser network tab for API calls
|
||||||
|
2. Check backend logs for errors
|
||||||
|
3. Verify database connections
|
||||||
|
4. Test API endpoints directly with Postman/curl
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **Password Reset Flow**
|
||||||
|
2. **Two-Factor Authentication**
|
||||||
|
3. **Social Login Integration**
|
||||||
|
4. **Account Lockout Protection**
|
||||||
|
5. **Session Management Dashboard**
|
||||||
|
6. **Audit Logging**
|
||||||
118
DYNAMIC_TEMPLATES.md
Normal file
118
DYNAMIC_TEMPLATES.md
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
# Dynamic Templates Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The frontend now fetches templates dynamically from the database instead of using static templates. This allows for real-time template management and custom template creation.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Template Service (`src/lib/template-service.ts`)
|
||||||
|
- Created service to communicate with the template-manager API
|
||||||
|
- Handles fetching templates by category, individual templates, and creating new templates
|
||||||
|
- Includes TypeScript interfaces for type safety
|
||||||
|
|
||||||
|
### 2. Custom Hook (`src/hooks/useTemplates.ts`)
|
||||||
|
- Manages template data fetching and state
|
||||||
|
- Converts database templates to UI format
|
||||||
|
- Handles loading states and error handling
|
||||||
|
- Provides template feature fetching
|
||||||
|
|
||||||
|
### 3. Custom Template Form (`src/components/custom-template-form.tsx`)
|
||||||
|
- Form component for creating new templates
|
||||||
|
- Includes all required fields: type, title, description, category
|
||||||
|
- Optional fields: icon, gradient, border, text, subtext
|
||||||
|
- Validates required fields before submission
|
||||||
|
|
||||||
|
### 4. Updated Main Dashboard (`src/components/main-dashboard.tsx`)
|
||||||
|
- Replaced static templates with dynamic database templates
|
||||||
|
- Added loading and error states
|
||||||
|
- Dynamic category generation based on available templates
|
||||||
|
- Custom template creation functionality
|
||||||
|
- Fallback to static templates if database is unavailable
|
||||||
|
|
||||||
|
### 5. UI Components
|
||||||
|
- Added `textarea.tsx` component for form inputs
|
||||||
|
- Enhanced existing components with proper styling
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
### Template Manager Service
|
||||||
|
- **Base URL**: `http://localhost:8009`
|
||||||
|
- **Endpoints**:
|
||||||
|
- `GET /api/templates` - Get all templates grouped by category
|
||||||
|
- `GET /api/templates/:id` - Get specific template with features
|
||||||
|
- `GET /api/templates/type/:type` - Get template by type
|
||||||
|
- `POST /api/templates` - Create new template
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
Templates are stored in PostgreSQL with the following structure:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE templates (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
type VARCHAR(100) UNIQUE,
|
||||||
|
title VARCHAR(200),
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(100),
|
||||||
|
icon VARCHAR(50),
|
||||||
|
gradient VARCHAR(100),
|
||||||
|
border VARCHAR(100),
|
||||||
|
text VARCHAR(100),
|
||||||
|
subtext VARCHAR(100),
|
||||||
|
is_active BOOLEAN,
|
||||||
|
created_at TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Viewing Templates
|
||||||
|
1. Templates are automatically loaded from the database on page load
|
||||||
|
2. If database is unavailable, fallback static templates are shown
|
||||||
|
3. Templates are grouped by category dynamically
|
||||||
|
|
||||||
|
### Creating Custom Templates
|
||||||
|
1. Click "Create Custom Template" button
|
||||||
|
2. Fill in required fields (type, title, description, category)
|
||||||
|
3. Optionally add styling fields (icon, gradient, border, text, subtext)
|
||||||
|
4. Submit to create the template in the database
|
||||||
|
5. Template will appear in the list after creation
|
||||||
|
|
||||||
|
### Template Features
|
||||||
|
- Each template can have associated features stored in `template_features` table
|
||||||
|
- Features are fetched when a template is selected
|
||||||
|
- Features include complexity, type, and usage statistics
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- Network errors show retry button
|
||||||
|
- Loading states with spinner
|
||||||
|
- Graceful fallback to static templates
|
||||||
|
- Form validation for required fields
|
||||||
|
|
||||||
|
## ✅ Implemented Features
|
||||||
|
|
||||||
|
### Template Management
|
||||||
|
- ✅ **Dynamic Template Display** - Templates fetched from database
|
||||||
|
- ✅ **Custom Template Creation** - Create new templates via form
|
||||||
|
- ✅ **Template Editing** - Edit existing templates
|
||||||
|
- ✅ **Template Deletion** - Delete templates with confirmation
|
||||||
|
- ✅ **Real-time Updates** - Changes reflect immediately in UI
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- ✅ `GET /api/templates` - Get all templates grouped by category
|
||||||
|
- ✅ `GET /api/templates/:id` - Get specific template with features
|
||||||
|
- ✅ `POST /api/templates` - Create new template
|
||||||
|
- ✅ `PUT /api/templates/:id` - Update existing template
|
||||||
|
- ✅ `DELETE /api/templates/:id` - Delete template (soft delete)
|
||||||
|
|
||||||
|
### Database Operations
|
||||||
|
- ✅ **Soft Delete** - Templates are marked as inactive rather than physically deleted
|
||||||
|
- ✅ **Data Integrity** - All operations maintain referential integrity
|
||||||
|
- ✅ **Error Handling** - Comprehensive error handling for all operations
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
- Feature management for templates
|
||||||
|
- Bulk template operations
|
||||||
|
- Template versioning
|
||||||
|
- Template sharing between users
|
||||||
|
- Template import/export functionality
|
||||||
|
- Template analytics and usage tracking
|
||||||
116
package-lock.json
generated
116
package-lock.json
generated
@ -18,6 +18,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
@ -3012,6 +3013,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@ -3038,6 +3045,17 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@ -3102,7 +3120,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@ -3258,6 +3275,18 @@
|
|||||||
"simple-swizzle": "^0.2.2"
|
"simple-swizzle": "^0.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@ -3409,6 +3438,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||||
@ -3442,7 +3480,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@ -3547,7 +3584,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -3557,7 +3593,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -3595,7 +3630,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@ -3608,7 +3642,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@ -4215,6 +4248,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@ -4231,11 +4284,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@ -4276,7 +4344,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@ -4310,7 +4377,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@ -4398,7 +4464,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -4477,7 +4542,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -4490,7 +4554,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@ -4506,7 +4569,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@ -5429,7 +5491,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@ -5459,6 +5520,27 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@ -5960,6 +6042,12 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
|
|||||||
170
src/app/auth/emailVerification.tsx
Normal file
170
src/app/auth/emailVerification.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type VerifyState = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
interface VerificationResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: { message: string; user: { email: string; username: string } };
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
interface ErrorResponse {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = "http://localhost:8011";
|
||||||
|
|
||||||
|
const EmailVerification: React.FC = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<VerifyState>("idle");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
const didRun = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus("error");
|
||||||
|
setError("No verification token found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (didRun.current) return;
|
||||||
|
didRun.current = true;
|
||||||
|
|
||||||
|
void verifyEmail(token);
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const verifyEmail = async (verificationToken: string) => {
|
||||||
|
setStatus("loading");
|
||||||
|
setMessage("Verifying your email...");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timeout = setTimeout(() => ctrl.abort(), 10000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/auth/verify-email?token=${verificationToken}`,
|
||||||
|
{ method: "GET", headers: { "Content-Type": "application/json" }, signal: ctrl.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
const txt = await res.text();
|
||||||
|
let data: any = {};
|
||||||
|
try { data = JSON.parse(txt || "{}"); } catch { /* ignore non-JSON */ }
|
||||||
|
|
||||||
|
if (res.ok && (data as VerificationResponse)?.success) {
|
||||||
|
router.replace("/auth?verified=1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = String(data?.message || "").toLowerCase();
|
||||||
|
if (msg.includes("already verified")) {
|
||||||
|
router.replace("/auth?verified=1");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("error");
|
||||||
|
setError(data?.message || `Verification failed (HTTP ${res.status}).`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(e?.name === "AbortError" ? "Request timed out. Please try again." : "Network error. Please try again.");
|
||||||
|
console.error("Email verification error:", e);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendVerification = async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
setMessage("Sending verification email...");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = prompt("Enter your email to resend the verification link:");
|
||||||
|
if (!email) {
|
||||||
|
setStatus("error");
|
||||||
|
setError("Email is required to resend verification.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/auth/resend-verification`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && data?.success) {
|
||||||
|
setStatus("success");
|
||||||
|
setMessage("Verification email sent! Please check your inbox.");
|
||||||
|
} else {
|
||||||
|
setStatus("error");
|
||||||
|
setError(data?.message || "Failed to resend verification email.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("error");
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
console.error("Resend verification error:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-1">Verifying Email</h2>
|
||||||
|
<p className="text-gray-600">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
|
||||||
|
<div className="max-w-md w-full space-y-6">
|
||||||
|
{status === "success" && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-md p-4 text-center">
|
||||||
|
<p className="text-green-800 font-medium">{message || "Email verified. Redirecting..."}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||||
|
<p className="text-red-800 font-medium text-center">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleResendVerification}
|
||||||
|
className="mt-4 w-full py-2 rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Resend Verification Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => (window.location.href = "/auth")}
|
||||||
|
className="w-1/2 py-2 rounded-md border bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Go to Login
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => (window.location.href = "/")}
|
||||||
|
className="w-1/2 py-2 rounded-md border bg-white text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Go to Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmailVerification;
|
||||||
@ -1,5 +1,33 @@
|
|||||||
import { AuthPage } from "@/components/auth/auth-page"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
export default function AuthPageRoute() {
|
export default function AuthPageRoute() {
|
||||||
return <AuthPage />
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user wants to sign up or sign in
|
||||||
|
const mode = searchParams.get('mode')
|
||||||
|
|
||||||
|
if (mode === 'signup') {
|
||||||
|
router.replace('/signup')
|
||||||
|
} else if (mode === 'signin') {
|
||||||
|
router.replace('/signin')
|
||||||
|
} else {
|
||||||
|
// Default to signin page
|
||||||
|
router.replace('/signin')
|
||||||
|
}
|
||||||
|
}, [router, searchParams])
|
||||||
|
|
||||||
|
// Show loading while redirecting
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-white/60">Redirecting...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/app/signin/page.tsx
Normal file
5
src/app/signin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SignInPage } from "@/components/auth/signin-page"
|
||||||
|
|
||||||
|
export default function SignInPageRoute() {
|
||||||
|
return <SignInPage />
|
||||||
|
}
|
||||||
5
src/app/signup/page.tsx
Normal file
5
src/app/signup/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SignUpPage } from "@/components/auth/signup-page"
|
||||||
|
|
||||||
|
export default function SignUpPageRoute() {
|
||||||
|
return <SignUpPage />
|
||||||
|
}
|
||||||
5
src/app/verify-email/page.tsx
Normal file
5
src/app/verify-email/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import EmailVerification from "@/app/auth/emailVerification";
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
return <EmailVerification />;
|
||||||
|
}
|
||||||
96
src/components/apis/authApiClients.tsx
Normal file
96
src/components/apis/authApiClients.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { safeLocalStorage, safeRedirect } from "@/lib/utils";
|
||||||
|
|
||||||
|
const API_BASE_URL = "http://localhost:8011";
|
||||||
|
|
||||||
|
let accessToken = safeLocalStorage.getItem('accessToken');
|
||||||
|
let refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||||
|
|
||||||
|
export const setTokens = (newAccessToken: string, newRefreshToken: string) => {
|
||||||
|
accessToken = newAccessToken;
|
||||||
|
refreshToken = newRefreshToken;
|
||||||
|
safeLocalStorage.setItem('accessToken', newAccessToken);
|
||||||
|
safeLocalStorage.setItem('refreshToken', newRefreshToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearTokens = () => {
|
||||||
|
accessToken = null;
|
||||||
|
refreshToken = null;
|
||||||
|
safeLocalStorage.removeItem('accessToken');
|
||||||
|
safeLocalStorage.removeItem('refreshToken');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAccessToken = () => accessToken;
|
||||||
|
export const getRefreshToken = () => refreshToken;
|
||||||
|
|
||||||
|
// Logout function that calls the backend API
|
||||||
|
export const logout = async () => {
|
||||||
|
try {
|
||||||
|
const refreshToken = getRefreshToken();
|
||||||
|
if (refreshToken) {
|
||||||
|
await authApiClient.post('/api/auth/logout', { refreshToken });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout API call failed:', error);
|
||||||
|
// Continue with logout even if API call fails
|
||||||
|
} finally {
|
||||||
|
// Always clear tokens and redirect
|
||||||
|
clearTokens();
|
||||||
|
// Clear any other user data
|
||||||
|
safeLocalStorage.removeItem('codenuk_user');
|
||||||
|
// Redirect to signin page
|
||||||
|
safeRedirect('/signin');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const authApiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add auth token to requests
|
||||||
|
const addAuthTokenInterceptor = (client: typeof authApiClient) => {
|
||||||
|
client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
if (accessToken) {
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle token refresh
|
||||||
|
const addTokenRefreshInterceptor = (client: typeof authApiClient) => {
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
try {
|
||||||
|
if (refreshToken) {
|
||||||
|
const response = await client.post('/api/auth/refresh', {
|
||||||
|
refreshToken: refreshToken
|
||||||
|
});
|
||||||
|
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response.data.data.tokens;
|
||||||
|
setTokens(newAccessToken, newRefreshToken);
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
|
return client(originalRequest);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error('Token refresh failed:', refreshError);
|
||||||
|
clearTokens();
|
||||||
|
window.location.href = '/auth';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
addAuthTokenInterceptor(authApiClient);
|
||||||
|
addTokenRefreshInterceptor(authApiClient);
|
||||||
75
src/components/apis/authenticationHandler.tsx
Normal file
75
src/components/apis/authenticationHandler.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { authApiClient } from "./authApiClients";
|
||||||
|
import { safeLocalStorage } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApiError extends Error {
|
||||||
|
response?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const registerUser = async (
|
||||||
|
data: {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
console.log("Registering user with data:", data);
|
||||||
|
try {
|
||||||
|
const response = await authApiClient.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error registering user:", error.response?.data || error.message);
|
||||||
|
|
||||||
|
// Create a proper error object that preserves the original response
|
||||||
|
const enhancedError: ApiError = new Error(
|
||||||
|
error.response?.data?.message || error.response?.data?.error || "Failed to register user. Please try again."
|
||||||
|
);
|
||||||
|
enhancedError.response = error.response;
|
||||||
|
throw enhancedError;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loginUser = async (email: string, password: string) => {
|
||||||
|
console.log("Logging in user with email:", email);
|
||||||
|
try {
|
||||||
|
const response = await authApiClient.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
{ email, password }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error logging in user:", error.response?.data || error.message);
|
||||||
|
|
||||||
|
// Create a proper error object that preserves the original response
|
||||||
|
const enhancedError: ApiError = new Error(
|
||||||
|
error.response?.data?.message || error.response?.data?.error || "Failed to log in. Please check your credentials."
|
||||||
|
);
|
||||||
|
enhancedError.response = error.response;
|
||||||
|
throw enhancedError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logoutUser = async () => {
|
||||||
|
console.log("Logging out user");
|
||||||
|
try {
|
||||||
|
const refreshToken = safeLocalStorage.getItem('refreshToken');
|
||||||
|
if (refreshToken) {
|
||||||
|
await authApiClient.post("/api/auth/logout", { refreshToken });
|
||||||
|
}
|
||||||
|
return { success: true, message: "Logged out successfully" };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error logging out user:", error.response?.data || error.message);
|
||||||
|
|
||||||
|
// Create a proper error object that preserves the original response
|
||||||
|
const enhancedError: ApiError = new Error(
|
||||||
|
error.response?.data?.message || error.response?.data?.error || "Failed to log out. Please try again."
|
||||||
|
);
|
||||||
|
enhancedError.response = error.response;
|
||||||
|
throw enhancedError;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/components/auth/emailVerification.tsx
Normal file
6
src/components/auth/emailVerification.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import EmailVerification from "@/app/auth/emailVerification";
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
return <EmailVerification />;
|
||||||
|
}
|
||||||
|
|
||||||
@ -7,14 +7,12 @@ import { useRouter } from "next/navigation"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
import { Eye, EyeOff, Loader2, Shield } from "lucide-react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { loginUser } from "@/components/apis/authenticationHandler"
|
||||||
|
import { setTokens } from "@/components/apis/authApiClients"
|
||||||
|
|
||||||
interface SignInFormProps {
|
export function SignInForm() {
|
||||||
onToggleMode: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SignInForm({ onToggleMode }: SignInFormProps) {
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
@ -23,23 +21,46 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
|||||||
password: "",
|
password: "",
|
||||||
})
|
})
|
||||||
|
|
||||||
const { login } = useAuth()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { setUserFromApi } = useAuth()
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError("")
|
setError("")
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
|
if (!formData.email || !formData.password) {
|
||||||
|
setError("Please fill in all required fields.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const success = await login(formData.email, formData.password)
|
const response = await loginUser(formData.email, formData.password)
|
||||||
if (success) {
|
|
||||||
|
if (response && response.data && response.data.tokens && response.data.user) {
|
||||||
|
setTokens(response.data.tokens.accessToken, response.data.tokens.refreshToken)
|
||||||
|
// Set user in context so header updates immediately
|
||||||
|
setUserFromApi(response.data.user)
|
||||||
|
// Persist for refresh
|
||||||
|
localStorage.setItem("codenuk_user", JSON.stringify(response.data.user))
|
||||||
|
// Go to main app
|
||||||
router.push("/")
|
router.push("/")
|
||||||
} else {
|
} else {
|
||||||
setError("Invalid email or password")
|
setError("Invalid response from server. Please try again.")
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Login error:', err)
|
||||||
|
|
||||||
|
// Handle different types of errors
|
||||||
|
if (err.response?.data?.message) {
|
||||||
|
setError(err.response.data.message)
|
||||||
|
} else if (err.response?.data?.error) {
|
||||||
|
setError(err.response.data.error)
|
||||||
|
} else if (err.message) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError("An error occurred during login. Please try again.")
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
setError("An error occurred. Please try again.")
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@ -47,7 +68,7 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email" className="text-white text-sm">Email</Label>
|
<Label htmlFor="email" className="text-white text-sm">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -57,7 +78,7 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
|||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
required
|
required
|
||||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30"
|
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -70,13 +91,13 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
|||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
required
|
required
|
||||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 pr-12"
|
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 pr-12"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5"
|
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
@ -86,11 +107,18 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-red-300 text-sm text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
<div className="text-red-300 text-sm text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
||||||
{error}
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full h-11 cursor-pointer bg-white text-black hover:bg-white/90" disabled={isLoading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full h-11 cursor-pointer bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700 transition-all duration-200 font-semibold shadow-lg hover:shadow-orange-500/25"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@ -100,11 +128,6 @@ export function SignInForm({ onToggleMode }: SignInFormProps) {
|
|||||||
"Sign In"
|
"Sign In"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="text-center">
|
|
||||||
<Button type="button" variant="link" onClick={onToggleMode} className="text-sm cursor-pointer text-white/70 hover:text-white">
|
|
||||||
Don't have an account? Sign up
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
124
src/components/auth/signin-page.tsx
Normal file
124
src/components/auth/signin-page.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { SignInForm } from "./signin-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { CheckCircle, AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
export function SignInPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [verificationMessage, setVerificationMessage] = useState<string | null>(null)
|
||||||
|
const [verificationType, setVerificationType] = useState<'success' | 'error'>('success')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for verification messages in URL
|
||||||
|
const verified = searchParams.get('verified')
|
||||||
|
const message = searchParams.get('message')
|
||||||
|
const error = searchParams.get('error')
|
||||||
|
|
||||||
|
if (verified === 'true') {
|
||||||
|
setVerificationMessage('Email verified successfully! You can now sign in to your account.')
|
||||||
|
setVerificationType('success')
|
||||||
|
} else if (message) {
|
||||||
|
setVerificationMessage(decodeURIComponent(message))
|
||||||
|
setVerificationType('success')
|
||||||
|
} else if (error) {
|
||||||
|
setVerificationMessage(decodeURIComponent(error))
|
||||||
|
setVerificationType('error')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the message after 5 seconds
|
||||||
|
if (verified || message || error) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setVerificationMessage(null)
|
||||||
|
}, 5000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||||
|
{/* Left: Gradient panel with steps */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||||
|
{/* subtle grain */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||||
|
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* soft circular accents */}
|
||||||
|
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||||
|
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||||
|
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||||
|
<div className="mb-12">
|
||||||
|
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl text-center font-bold mb-3">Welcome Back!</h2>
|
||||||
|
<p className="text-white/80 text-center mb-10">Sign in to access your workspace and continue building.</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Step 1 - Completed */}
|
||||||
|
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">1</div>
|
||||||
|
<span className="font-medium">Sign up your account</span>
|
||||||
|
</div>
|
||||||
|
{/* Step 2 - Active */}
|
||||||
|
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-black text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||||
|
<span className="font-medium">Sign in to workspace</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Form area */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-full max-w-md ml-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-white mb-1">Sign In Account</h1>
|
||||||
|
<p className="text-white/60">Enter your credentials to access your account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verification Message */}
|
||||||
|
{verificationMessage && (
|
||||||
|
<div className={`mb-6 p-4 rounded-lg border ${
|
||||||
|
verificationType === 'success'
|
||||||
|
? 'bg-green-500/10 border-green-500/30 text-green-300'
|
||||||
|
: 'bg-red-500/10 border-red-500/30 text-red-300'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{verificationType === 'success' ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm">{verificationMessage}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SignInForm />
|
||||||
|
|
||||||
|
<div className="text-center pt-4">
|
||||||
|
<div className="text-white/60 text-xs mb-1">Don't have an account?</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
onClick={() => router.push("/signup")}
|
||||||
|
className="text-orange-400 hover:text-orange-300 font-medium transition-colors text-sm p-0 h-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
Create a new account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,23 +8,27 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
import { Eye, EyeOff, Loader2, User, Mail, Lock, Shield } from "lucide-react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
|
import { registerUser } from "../apis/authenticationHandler"
|
||||||
|
|
||||||
interface SignUpFormProps {
|
interface SignUpFormProps {
|
||||||
onToggleMode: () => void
|
onSignUpSuccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignUpForm({ onToggleMode }: SignUpFormProps) {
|
export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
username: "",
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
|
role: "user" // default role, adjust as needed
|
||||||
})
|
})
|
||||||
|
|
||||||
const { signup } = useAuth()
|
const { signup } = useAuth()
|
||||||
@ -34,139 +38,228 @@ export function SignUpForm({ onToggleMode }: SignUpFormProps) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
|
// Validation
|
||||||
if (formData.password !== formData.confirmPassword) {
|
if (formData.password !== formData.confirmPassword) {
|
||||||
setError("Passwords don't match")
|
setError("Passwords don't match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!formData.username || !formData.first_name || !formData.last_name || !formData.email || !formData.password) {
|
||||||
|
setError("Please fill in all required fields.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password strength validation
|
||||||
|
if (formData.password.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await signup(formData.name, formData.email, formData.password)
|
const response = await registerUser({
|
||||||
if (success) {
|
username: formData.username,
|
||||||
router.push("/")
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
first_name: formData.first_name,
|
||||||
|
last_name: formData.last_name,
|
||||||
|
role: formData.role
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// Call success callback if provided
|
||||||
|
if (onSignUpSuccess) {
|
||||||
|
onSignUpSuccess()
|
||||||
|
} else {
|
||||||
|
// Default behavior - redirect to signin with message
|
||||||
|
router.push("/signin?message=Account created successfully! Please check your email to verify your account.")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError("Failed to create account. Please try again.")
|
setError(response.message || "Failed to create account. Please try again.")
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Signup error:', err)
|
||||||
|
|
||||||
|
// Handle different types of errors
|
||||||
|
if (err.response?.data?.message) {
|
||||||
|
setError(err.response.data.message)
|
||||||
|
} else if (err.message) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError("An error occurred during registration. Please try again.")
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
setError("An error occurred. Please try again.")
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-md bg-transparent border-0 shadow-none">
|
<div className="w-full max-w-md">
|
||||||
<CardHeader className="space-y-1 p-0 mb-2">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<CardTitle className="sr-only">Sign Up</CardTitle>
|
{/* Personal Information Section */}
|
||||||
<CardDescription className="sr-only">Create your account to get started</CardDescription>
|
<div className="space-y-3">
|
||||||
</CardHeader>
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
<CardContent className="p-0">
|
<User className="h-4 w-4 text-orange-400" />
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<h3 className="text-base font-semibold text-white">Personal Information</h3>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name" className="text-white text-sm">Full Name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
placeholder="Enter your full name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
required
|
|
||||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email" className="text-white text-sm">Email</Label>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="first_name" className="text-white/80 text-xs font-medium">First Name</Label>
|
||||||
|
<Input
|
||||||
|
id="first_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John"
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||||
|
required
|
||||||
|
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="last_name" className="text-white/80 text-xs font-medium">Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id="last_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Doe"
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||||
|
required
|
||||||
|
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="username" className="text-white/80 text-xs font-medium">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="johndoe123"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
required
|
||||||
|
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="role" className="text-white/80 text-xs font-medium">Role</Label>
|
||||||
|
<Input
|
||||||
|
id="role"
|
||||||
|
type="text"
|
||||||
|
placeholder="user"
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||||
|
required
|
||||||
|
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Information Section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
|
<Mail className="h-4 w-4 text-orange-400" />
|
||||||
|
<h3 className="text-base font-semibold text-white">Contact Information</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="email" className="text-white/80 text-xs font-medium">Email Address</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Enter your email"
|
placeholder="john.doe@example.com"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
required
|
required
|
||||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30"
|
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<Label htmlFor="password" className="text-white text-sm">Password</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
||||||
required
|
|
||||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 pr-12"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="confirmPassword" className="text-white text-sm">Confirm Password</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="confirmPassword"
|
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
value={formData.confirmPassword}
|
|
||||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
|
||||||
required
|
|
||||||
className="h-11 bg-white/5 border-white/10 text-white placeholder:text-white/40 focus:border-white/30 focus:ring-white/30 pr-12"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 text-white/60 hover:text-white hover:bg-white/5"
|
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
||||||
>
|
|
||||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-red-300 text-sm text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full h-11 cursor-pointer bg-white text-black hover:bg-white/90"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Creating Account...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Sign Up"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<div className="text-center">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
onClick={onToggleMode}
|
|
||||||
className="text-sm cursor-pointer text-white/70 hover:text-white"
|
|
||||||
>
|
|
||||||
Already have an account? Sign in
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
{/* Security Section */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
|
<Lock className="h-4 w-4 text-orange-400" />
|
||||||
|
<h3 className="text-base font-semibold text-white">Security</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="password" className="text-white/80 text-xs font-medium">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-2 py-1 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="confirmPassword" className="text-white/80 text-xs font-medium">Confirm</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
|
placeholder="Confirm"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||||
|
required
|
||||||
|
className="h-10 bg-white/5 border-white/10 text-white placeholder:text-white/30 focus:border-orange-400/50 focus:ring-orange-400/20 transition-all duration-200 text-sm pr-10"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0 top-0 h-full px-2 py-1 text-white/60 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-300 text-xs text-center bg-red-900/20 p-3 rounded-md border border-red-500/30">
|
||||||
|
<div className="flex items-center justify-center space-x-1">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full h-10 cursor-pointer bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700 transition-all duration-200 font-semibold text-sm shadow-lg hover:shadow-orange-500/25"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Creating Account...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create Account"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/components/auth/signup-page.tsx
Normal file
157
src/components/auth/signup-page.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { SignUpForm } from "./signup-form"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { CheckCircle, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
export function SignUpPage() {
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleSignUpSuccess = () => {
|
||||||
|
setIsSuccess(true)
|
||||||
|
// Redirect to signin after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push("/signin?message=Please check your email to verify your account")
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||||
|
{/* Left: Gradient panel */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||||
|
{/* subtle grain */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||||
|
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* soft circular accents */}
|
||||||
|
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||||
|
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||||
|
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||||
|
<div className="mb-12">
|
||||||
|
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl text-center font-bold mb-3">Account Created!</h2>
|
||||||
|
<p className="text-white/80 text-center mb-10">Your account has been successfully created. Please verify your email to continue.</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Step 1 - Completed */}
|
||||||
|
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-green-500 text-white flex items-center justify-center mr-4 text-sm font-semibold">
|
||||||
|
<CheckCircle className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">Sign up completed</span>
|
||||||
|
</div>
|
||||||
|
{/* Step 2 - Next */}
|
||||||
|
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||||
|
<span className="font-medium">Verify email & sign in</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Success message */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-full max-w-md ml-auto text-center">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<CheckCircle className="h-10 w-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white mb-2">Account Created Successfully!</h1>
|
||||||
|
<p className="text-white/60 mb-6">We've sent a verification email to your inbox. Please check your email and click the verification link to activate your account.</p>
|
||||||
|
<div className="bg-orange-500/10 border border-orange-500/30 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-orange-300 text-sm">
|
||||||
|
<strong>Next step:</strong> Check your email and click the verification link, then sign in to your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push("/signin")}
|
||||||
|
className="bg-gradient-to-r from-orange-500 to-orange-600 text-white hover:from-orange-600 hover:to-orange-700"
|
||||||
|
>
|
||||||
|
Go to Sign In
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-6xl grid grid-cols-1 lg:grid-cols-2 gap-10">
|
||||||
|
{/* Left: Gradient panel with steps */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<div className="relative h-full rounded-3xl overflow-hidden border border-white/10 bg-gradient-to-b from-orange-400 via-orange-500 to-black p-10">
|
||||||
|
{/* subtle grain */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-20 [mask-image:radial-gradient(ellipse_at_center,black_70%,transparent_100%)]">
|
||||||
|
<div className="w-full h-full bg-[radial-gradient(circle_at_1px_1px,rgba(255,255,255,0.12)_1px,transparent_0)] bg-[length:18px_18px]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* soft circular accents */}
|
||||||
|
<div className="pointer-events-none absolute -top-10 -right-10 w-72 h-72 rounded-full blur-3xl bg-purple-300/40"></div>
|
||||||
|
<div className="pointer-events-none absolute top-1/3 -left-10 w-56 h-56 rounded-full blur-2xl bg-indigo-300/25"></div>
|
||||||
|
<div className="pointer-events-none absolute -bottom-12 left-1/3 w-64 h-64 rounded-full blur-3xl bg-purple-400/20"></div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex h-full flex-col justify-center">
|
||||||
|
<div className="mb-12">
|
||||||
|
<p className="text-5xl font-bold text-center text-white/80">Codenuk</p>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl text-center font-bold mb-3">Get Started with Us</h2>
|
||||||
|
<p className="text-white/80 text-center mb-10">Complete these easy steps to register your account.</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Step 1 - Active */}
|
||||||
|
<div className="bg-white text-black rounded-xl p-4 flex items-center">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-black text-white flex items-center justify-center mr-4 text-sm font-semibold">1</div>
|
||||||
|
<span className="font-medium">Sign up your account</span>
|
||||||
|
</div>
|
||||||
|
{/* Step 2 - Next */}
|
||||||
|
<div className="bg-white/10 text-white border border-white/20 rounded-xl p-4 flex items-center">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-white/20 text-white flex items-center justify-center mr-4 text-sm font-semibold">2</div>
|
||||||
|
<span className="font-medium">Verify email & sign in</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Form area */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-full max-w-md ml-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-semibold text-white mb-1">Sign Up Account</h1>
|
||||||
|
<p className="text-white/60">Enter your personal data to create your account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignUpForm onSignUpSuccess={handleSignUpSuccess} />
|
||||||
|
|
||||||
|
<div className="text-center pt-4">
|
||||||
|
<div className="text-white/60 text-xs mb-1">Already have an account?</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
onClick={() => router.push("/signin")}
|
||||||
|
className="text-orange-400 hover:text-orange-300 font-medium transition-colors text-sm p-0 h-auto cursor-pointer"
|
||||||
|
>
|
||||||
|
Sign in to your account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
src/components/custom-template-form.tsx
Normal file
210
src/components/custom-template-form.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { DatabaseTemplate } from "@/lib/template-service"
|
||||||
|
import { Plus, X, Save } from "lucide-react"
|
||||||
|
|
||||||
|
interface CustomTemplateFormProps {
|
||||||
|
onSubmit: (templateData: Partial<DatabaseTemplate>) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomTemplateForm({ onSubmit, onCancel }: CustomTemplateFormProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
type: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
category: "",
|
||||||
|
icon: "",
|
||||||
|
gradient: "",
|
||||||
|
border: "",
|
||||||
|
text: "",
|
||||||
|
subtext: ""
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
"Food Delivery",
|
||||||
|
"E-commerce",
|
||||||
|
"SaaS Platform",
|
||||||
|
"Mobile App",
|
||||||
|
"Dashboard",
|
||||||
|
"CRM System",
|
||||||
|
"Learning Platform",
|
||||||
|
"Healthcare",
|
||||||
|
"Real Estate",
|
||||||
|
"Travel",
|
||||||
|
"Entertainment",
|
||||||
|
"Finance",
|
||||||
|
"Social Media",
|
||||||
|
"Marketplace",
|
||||||
|
"Other"
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(formData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating template:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center">
|
||||||
|
<Plus className="mr-2 h-5 w-5" />
|
||||||
|
Create Custom Template
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Template Type *</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., multi_restaurant_food_delivery"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-white/60">Unique identifier for the template</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Title *</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Description *</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe your template and its key features..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40 min-h-[100px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Category *</label>
|
||||||
|
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
|
||||||
|
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||||
|
<SelectValue placeholder="Select a category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-gray-900 border-white/10">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category} value={category} className="text-white">
|
||||||
|
{category}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Icon (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., restaurant, shopping-cart, users"
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Gradient (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., from-orange-400 to-red-500"
|
||||||
|
value={formData.gradient}
|
||||||
|
onChange={(e) => handleInputChange('gradient', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Border (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., border-orange-500"
|
||||||
|
value={formData.border}
|
||||||
|
onChange={(e) => handleInputChange('border', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Text Color (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., text-orange-500"
|
||||||
|
value={formData.text}
|
||||||
|
onChange={(e) => handleInputChange('text', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Subtext (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., Perfect for food delivery startups"
|
||||||
|
value={formData.subtext}
|
||||||
|
onChange={(e) => handleInputChange('subtext', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="border-white/20 text-white hover:bg-white/10 cursor-pointer"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold cursor-pointer"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{loading ? "Creating..." : "Create Template"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/components/delete-confirmation-dialog.tsx
Normal file
60
src/components/delete-confirmation-dialog.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { AlertTriangle, Trash2, X } from "lucide-react"
|
||||||
|
|
||||||
|
interface DeleteConfirmationDialogProps {
|
||||||
|
templateTitle: string
|
||||||
|
onConfirm: () => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmationDialog({
|
||||||
|
templateTitle,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
loading = false
|
||||||
|
}: DeleteConfirmationDialogProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<Card className="bg-white/5 border-white/10 max-w-md w-full mx-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center">
|
||||||
|
<AlertTriangle className="mr-2 h-5 w-5 text-red-400" />
|
||||||
|
Delete Template
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-white/80">
|
||||||
|
Are you sure you want to delete the template <strong className="text-white">"{templateTitle}"</strong>?
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
This action cannot be undone. The template will be permanently removed from the database.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-red-500 hover:bg-red-400 text-white"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{loading ? "Deleting..." : "Delete Template"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
225
src/components/edit-template-form.tsx
Normal file
225
src/components/edit-template-form.tsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { DatabaseTemplate } from "@/lib/template-service"
|
||||||
|
import { Edit, X, Save } from "lucide-react"
|
||||||
|
|
||||||
|
interface EditTemplateFormProps {
|
||||||
|
template: DatabaseTemplate
|
||||||
|
onSubmit: (id: string, templateData: Partial<DatabaseTemplate>) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditTemplateForm({ template, onSubmit, onCancel }: EditTemplateFormProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
type: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
category: "",
|
||||||
|
icon: "",
|
||||||
|
gradient: "",
|
||||||
|
border: "",
|
||||||
|
text: "",
|
||||||
|
subtext: ""
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
"Food Delivery",
|
||||||
|
"E-commerce",
|
||||||
|
"SaaS Platform",
|
||||||
|
"Mobile App",
|
||||||
|
"Dashboard",
|
||||||
|
"CRM System",
|
||||||
|
"Learning Platform",
|
||||||
|
"Healthcare",
|
||||||
|
"Real Estate",
|
||||||
|
"Travel",
|
||||||
|
"Entertainment",
|
||||||
|
"Finance",
|
||||||
|
"Social Media",
|
||||||
|
"Marketplace",
|
||||||
|
"Other"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Initialize form data with template values
|
||||||
|
useEffect(() => {
|
||||||
|
setFormData({
|
||||||
|
type: template.type || "",
|
||||||
|
title: template.title || "",
|
||||||
|
description: template.description || "",
|
||||||
|
category: template.category || "",
|
||||||
|
icon: template.icon || "",
|
||||||
|
gradient: template.gradient || "",
|
||||||
|
border: template.border || "",
|
||||||
|
text: template.text || "",
|
||||||
|
subtext: template.subtext || ""
|
||||||
|
})
|
||||||
|
}, [template])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit(template.id, formData)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating template:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center">
|
||||||
|
<Edit className="mr-2 h-5 w-5" />
|
||||||
|
Edit Template: {template.title}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Template Type *</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., multi_restaurant_food_delivery"
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-white/60">Unique identifier for the template</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Title *</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., Multi-Restaurant Food Delivery App"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => handleInputChange('title', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Description *</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe your template and its key features..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40 min-h-[100px]"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Category *</label>
|
||||||
|
<Select value={formData.category} onValueChange={(value) => handleInputChange('category', value)}>
|
||||||
|
<SelectTrigger className="bg-white/5 border-white/10 text-white">
|
||||||
|
<SelectValue placeholder="Select a category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-gray-900 border-white/10">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category} value={category} className="text-white">
|
||||||
|
{category}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Icon (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., restaurant, shopping-cart, users"
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(e) => handleInputChange('icon', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Gradient (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., from-orange-400 to-red-500"
|
||||||
|
value={formData.gradient}
|
||||||
|
onChange={(e) => handleInputChange('gradient', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Border (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., border-orange-500"
|
||||||
|
value={formData.border}
|
||||||
|
onChange={(e) => handleInputChange('border', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Text Color (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., text-orange-500"
|
||||||
|
value={formData.text}
|
||||||
|
onChange={(e) => handleInputChange('text', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white">Subtext (optional)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g., Perfect for food delivery startups"
|
||||||
|
value={formData.subtext}
|
||||||
|
onChange={(e) => handleInputChange('subtext', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-orange-500 hover:bg-orange-400 text-black font-semibold"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{loading ? "Updating..." : "Update Template"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
// Don't show header on auth pages
|
// Don't show header on auth pages
|
||||||
const isAuthPage = pathname === "/auth"
|
const isAuthPage = pathname === "/auth" || pathname === "/signin" || pathname === "/signup"
|
||||||
|
|
||||||
// Show loading state while checking auth
|
// Show loading state while checking auth
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -39,7 +39,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For unauthenticated users on non-auth pages, redirect to auth
|
// For unauthenticated users on non-auth pages, show header but redirect to auth
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers } from "lucide-react"
|
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2 } from "lucide-react"
|
||||||
|
import { useTemplates } from "@/hooks/useTemplates"
|
||||||
|
import { CustomTemplateForm } from "@/components/custom-template-form"
|
||||||
|
import { EditTemplateForm } from "@/components/edit-template-form"
|
||||||
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||||
|
import { DatabaseTemplate, TemplateFeature } from "@/lib/template-service"
|
||||||
|
|
||||||
interface Template {
|
interface Template {
|
||||||
id: string
|
id: string
|
||||||
@ -19,56 +24,74 @@ interface Template {
|
|||||||
techStack: string[]
|
techStack: string[]
|
||||||
popularity?: number
|
popularity?: number
|
||||||
lastUpdated?: string
|
lastUpdated?: string
|
||||||
|
type?: string
|
||||||
|
icon?: string | null
|
||||||
|
gradient?: string | null
|
||||||
|
border?: string | null
|
||||||
|
text?: string | null
|
||||||
|
subtext?: string | null
|
||||||
|
is_active?: boolean
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => void }) {
|
function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => void }) {
|
||||||
const [selectedCategory, setSelectedCategory] = useState("all")
|
const [selectedCategory, setSelectedCategory] = useState("all")
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [showCustomForm, setShowCustomForm] = useState(false)
|
||||||
const templates: Template[] = [
|
const [editingTemplate, setEditingTemplate] = useState<DatabaseTemplate | null>(null)
|
||||||
// Marketing Templates (10)
|
const [deletingTemplate, setDeletingTemplate] = useState<DatabaseTemplate | null>(null)
|
||||||
{ id: "marketing-website", title: "Marketing Website", description: "Professional marketing site with CMS and lead generation", category: "marketing", features: ["Content Management", "Contact Forms", "SEO Optimization", "Analytics Integration"], complexity: 2, timeEstimate: "1-2 weeks", techStack: ["Next.js", "Sanity CMS", "Tailwind CSS", "Vercel"], popularity: 95, lastUpdated: "2024-01-15" },
|
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||||
{ id: "landing-page", title: "Landing Page", description: "High-converting landing page with A/B testing capabilities", category: "marketing", features: ["A/B Testing", "Conversion Tracking", "Lead Capture", "Mobile Optimization"], complexity: 2, timeEstimate: "3-5 days", techStack: ["Next.js", "Tailwind CSS", "Google Analytics", "Mailchimp"], popularity: 88, lastUpdated: "2024-01-10" },
|
|
||||||
{ id: "blog-platform", title: "Blog Platform", description: "Content-rich blog with SEO optimization and social sharing", category: "marketing", features: ["Content Management", "SEO Tools", "Social Sharing", "Comment System"], complexity: 3, timeEstimate: "1-2 weeks", techStack: ["Next.js", "MDX", "Tailwind CSS", "Disqus"], popularity: 82, lastUpdated: "2024-01-12" },
|
const { templates: dbTemplates, loading, error, getTemplatesForUI, createTemplate, updateTemplate, deleteTemplate } = useTemplates()
|
||||||
{ id: "portfolio-site", title: "Portfolio Website", description: "Personal or agency portfolio with project showcase", category: "marketing", features: ["Project Gallery", "Contact Forms", "Blog", "Responsive Design"], complexity: 2, timeEstimate: "1-2 weeks", techStack: ["Next.js", "MDX", "Tailwind CSS", "Framer Motion"], popularity: 79, lastUpdated: "2024-01-08" },
|
|
||||||
{ id: "agency-website", title: "Agency Website", description: "Full-service agency site with team profiles and case studies", category: "marketing", features: ["Team Profiles", "Case Studies", "Service Pages", "Client Testimonials"], complexity: 3, timeEstimate: "2-3 weeks", techStack: ["Next.js", "Strapi", "Tailwind CSS", "Framer Motion"], popularity: 76, lastUpdated: "2024-01-14" },
|
// Get templates from database and convert to UI format
|
||||||
{ id: "event-website", title: "Event Website", description: "Event promotion site with registration and ticketing", category: "marketing", features: ["Event Registration", "Ticketing", "Speaker Profiles", "Schedule Management"], complexity: 4, timeEstimate: "2-3 weeks", techStack: ["Next.js", "Stripe", "Calendar API", "Email Integration"], popularity: 73, lastUpdated: "2024-01-11" },
|
const databaseTemplates = getTemplatesForUI()
|
||||||
{ id: "restaurant-website", title: "Restaurant Website", description: "Restaurant site with menu, reservations, and online ordering", category: "marketing", features: ["Menu Display", "Online Reservations", "Order System", "Location Info"], complexity: 3, timeEstimate: "1-2 weeks", techStack: ["Next.js", "Reservation API", "Payment Processing", "Google Maps"], popularity: 70, lastUpdated: "2024-01-09" },
|
|
||||||
{ id: "nonprofit-website", title: "Nonprofit Website", description: "Nonprofit organization site with donation and volunteer management", category: "marketing", features: ["Donation Processing", "Volunteer Registration", "Event Management", "Impact Tracking"], complexity: 3, timeEstimate: "2-3 weeks", techStack: ["Next.js", "Stripe", "Volunteer API", "Analytics"], popularity: 67, lastUpdated: "2024-01-13" },
|
// Fallback static templates if database is empty or loading
|
||||||
{ id: "real-estate-website", title: "Real Estate Website", description: "Property listing site with search and contact features", category: "marketing", features: ["Property Listings", "Search Filters", "Contact Forms", "Virtual Tours"], complexity: 4, timeEstimate: "2-4 weeks", techStack: ["Next.js", "Property API", "Map Integration", "Image Gallery"], popularity: 64, lastUpdated: "2024-01-07" },
|
const fallbackTemplates: Template[] = [
|
||||||
{ id: "personal-brand", title: "Personal Brand Site", description: "Personal branding site for professionals and creators", category: "marketing", features: ["About Page", "Services", "Testimonials", "Contact Integration"], complexity: 2, timeEstimate: "1 week", techStack: ["Next.js", "Tailwind CSS", "Contact Forms", "Social Links"], popularity: 61, lastUpdated: "2024-01-06" },
|
{ id: "marketing-website", title: "Marketing Website", description: "Professional marketing site with CMS and lead generation", category: "Marketing", features: ["Content Management", "Contact Forms", "SEO Optimization", "Analytics Integration"], complexity: 2, timeEstimate: "1-2 weeks", techStack: ["Next.js", "Sanity CMS", "Tailwind CSS", "Vercel"], popularity: 95, lastUpdated: "2024-01-15" },
|
||||||
|
{ id: "saas-platform", title: "SaaS Platform", description: "Complete SaaS application with user management, billing, and analytics", category: "SaaS Platform", features: ["User Authentication", "Payment Processing", "Analytics Integration", "API Management"], complexity: 5, timeEstimate: "4-6 weeks", techStack: ["Next.js", "PostgreSQL", "Stripe", "NextAuth.js"], popularity: 92, lastUpdated: "2024-01-15" },
|
||||||
// Software Templates (10)
|
|
||||||
{ id: "saas-platform", title: "SaaS Platform", description: "Complete SaaS application with user management, billing, and analytics", category: "software", features: ["User Authentication", "Payment Processing", "Analytics Integration", "API Management"], complexity: 5, timeEstimate: "4-6 weeks", techStack: ["Next.js", "PostgreSQL", "Stripe", "NextAuth.js"], popularity: 92, lastUpdated: "2024-01-15" },
|
|
||||||
{ id: "dashboard-app", title: "Analytics Dashboard", description: "Data visualization dashboard with real-time updates", category: "software", features: ["Data Visualization", "Real-time Updates", "User Authentication", "Export Features"], complexity: 4, timeEstimate: "3-4 weeks", techStack: ["Next.js", "Chart.js", "WebSockets", "PostgreSQL"], popularity: 89, lastUpdated: "2024-01-14" },
|
|
||||||
{ id: "mobile-app", title: "Mobile App (PWA)", description: "Progressive web app with mobile-first design", category: "software", features: ["Offline Support", "Push Notifications", "Mobile Optimization", "App Install"], complexity: 4, timeEstimate: "2-3 weeks", techStack: ["Next.js", "PWA", "Service Workers", "Push API"], popularity: 86, lastUpdated: "2024-01-13" },
|
|
||||||
{ id: "project-management", title: "Project Management Tool", description: "Team collaboration and project tracking application", category: "software", features: ["Task Management", "Team Collaboration", "Time Tracking", "Reporting"], complexity: 5, timeEstimate: "4-5 weeks", techStack: ["Next.js", "PostgreSQL", "Real-time Updates", "File Upload"], popularity: 83, lastUpdated: "2024-01-12" },
|
|
||||||
{ id: "crm-system", title: "CRM System", description: "Customer relationship management with sales pipeline", category: "software", features: ["Contact Management", "Sales Pipeline", "Email Integration", "Reporting"], complexity: 5, timeEstimate: "3-5 weeks", techStack: ["Next.js", "PostgreSQL", "Email API", "Calendar Integration"], popularity: 80, lastUpdated: "2024-01-11" },
|
|
||||||
{ id: "inventory-management", title: "Inventory Management", description: "Stock tracking and warehouse management system", category: "software", features: ["Stock Tracking", "Barcode Scanning", "Supplier Management", "Reports"], complexity: 4, timeEstimate: "3-4 weeks", techStack: ["Next.js", "PostgreSQL", "Barcode API", "PDF Generation"], popularity: 77, lastUpdated: "2024-01-10" },
|
|
||||||
{ id: "learning-platform", title: "Learning Management System", description: "Online education platform with courses and assessments", category: "software", features: ["Course Management", "Video Streaming", "Assessments", "Progress Tracking"], complexity: 5, timeEstimate: "4-6 weeks", techStack: ["Next.js", "Video API", "PostgreSQL", "Payment Processing"], popularity: 74, lastUpdated: "2024-01-09" },
|
|
||||||
{ id: "booking-system", title: "Booking System", description: "Appointment and reservation management platform", category: "software", features: ["Calendar Integration", "Payment Processing", "Notifications", "Customer Management"], complexity: 4, timeEstimate: "2-4 weeks", techStack: ["Next.js", "Calendar API", "Stripe", "Email Integration"], popularity: 71, lastUpdated: "2024-01-08" },
|
|
||||||
{ id: "chat-application", title: "Chat Application", description: "Real-time messaging platform with file sharing", category: "software", features: ["Real-time Messaging", "File Sharing", "Group Chats", "User Presence"], complexity: 4, timeEstimate: "2-3 weeks", techStack: ["Next.js", "WebSockets", "File Storage", "Real-time DB"], popularity: 68, lastUpdated: "2024-01-07" },
|
|
||||||
{ id: "api-platform", title: "API Platform", description: "RESTful API with documentation and rate limiting", category: "software", features: ["API Documentation", "Rate Limiting", "Authentication", "Monitoring"], complexity: 4, timeEstimate: "2-3 weeks", techStack: ["Next.js", "Swagger", "Redis", "Monitoring Tools"], popularity: 65, lastUpdated: "2024-01-06" },
|
|
||||||
|
|
||||||
// SEO Templates (10)
|
|
||||||
{ id: "seo-optimized-blog", title: "SEO-Optimized Blog", description: "Blog platform with advanced SEO features and schema markup", category: "seo", features: ["Schema Markup", "Meta Optimization", "Sitemap Generation", "Performance Optimization"], complexity: 3, timeEstimate: "2-3 weeks", techStack: ["Next.js", "SEO Tools", "Schema.org", "Google Search Console"], popularity: 90, lastUpdated: "2024-01-15" },
|
|
||||||
{ id: "local-business-site", title: "Local Business Website", description: "Local SEO optimized site with Google My Business integration", category: "seo", features: ["Local SEO", "Google My Business", "Review Management", "Location Pages"], complexity: 3, timeEstimate: "1-2 weeks", techStack: ["Next.js", "Google APIs", "Review APIs", "Local Schema"], popularity: 87, lastUpdated: "2024-01-14" },
|
|
||||||
{ id: "ecommerce-seo", title: "E-commerce SEO Site", description: "Product-focused e-commerce with advanced SEO optimization", category: "seo", features: ["Product Schema", "Category Optimization", "Review Rich Snippets", "Performance"], complexity: 4, timeEstimate: "3-4 weeks", techStack: ["Next.js", "Product APIs", "Review Systems", "CDN"], popularity: 84, lastUpdated: "2024-01-13" },
|
|
||||||
{ id: "news-website", title: "News Website", description: "News platform with article SEO and AMP support", category: "seo", features: ["Article Schema", "AMP Support", "Breaking News", "Social Sharing"], complexity: 4, timeEstimate: "2-4 weeks", techStack: ["Next.js", "AMP", "News APIs", "Social APIs"], popularity: 81, lastUpdated: "2024-01-12" },
|
|
||||||
{ id: "directory-website", title: "Business Directory", description: "Local business directory with search and listings", category: "seo", features: ["Business Listings", "Search Optimization", "Category Pages", "Review System"], complexity: 4, timeEstimate: "3-4 weeks", techStack: ["Next.js", "Search APIs", "Location Services", "Review APIs"], popularity: 78, lastUpdated: "2024-01-11" },
|
|
||||||
{ id: "recipe-website", title: "Recipe Website", description: "Recipe platform with rich snippets and cooking schema", category: "seo", features: ["Recipe Schema", "Nutrition Info", "Cooking Times", "User Ratings"], complexity: 3, timeEstimate: "2-3 weeks", techStack: ["Next.js", "Recipe APIs", "Nutrition APIs", "Rating System"], popularity: 75, lastUpdated: "2024-01-10" },
|
|
||||||
{ id: "job-board", title: "Job Board", description: "Job listing platform with structured data for search engines", category: "seo", features: ["Job Schema", "Search Filters", "Application Tracking", "Company Profiles"], complexity: 4, timeEstimate: "3-4 weeks", techStack: ["Next.js", "Job APIs", "Application System", "Company APIs"], popularity: 72, lastUpdated: "2024-01-09" },
|
|
||||||
{ id: "review-website", title: "Review Website", description: "Product/service review platform with rich snippets", category: "seo", features: ["Review Schema", "Rating System", "Comparison Tools", "User Profiles"], complexity: 4, timeEstimate: "2-4 weeks", techStack: ["Next.js", "Review APIs", "Rating System", "Comparison Tools"], popularity: 69, lastUpdated: "2024-01-08" },
|
|
||||||
{ id: "travel-website", title: "Travel Website", description: "Travel guide with location-based SEO and booking integration", category: "seo", features: ["Location Schema", "Travel Guides", "Booking Integration", "Photo Galleries"], complexity: 4, timeEstimate: "3-4 weeks", techStack: ["Next.js", "Travel APIs", "Booking APIs", "Map Integration"], popularity: 66, lastUpdated: "2024-01-07" },
|
|
||||||
{ id: "healthcare-website", title: "Healthcare Website", description: "Medical practice website with health-focused SEO", category: "seo", features: ["Medical Schema", "Appointment Booking", "Health Articles", "Doctor Profiles"], complexity: 3, timeEstimate: "2-3 weeks", techStack: ["Next.js", "Medical APIs", "Booking System", "Content Management"], popularity: 63, lastUpdated: "2024-01-06" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Use database templates if available, otherwise fallback
|
||||||
|
const templates: Template[] = databaseTemplates.length > 0 ? databaseTemplates : fallbackTemplates
|
||||||
|
|
||||||
const categories = [
|
// Generate dynamic categories from templates
|
||||||
{ id: "all", name: "All Templates", icon: Globe, count: templates.length },
|
const getCategories = () => {
|
||||||
{ id: "marketing", name: "Marketing & Branding", icon: Zap, count: templates.filter((t) => t.category === "marketing").length },
|
const categoryMap = new Map<string, { name: string; icon: any; count: number }>()
|
||||||
{ id: "software", name: "Software & Tools", icon: Code, count: templates.filter((t) => t.category === "software").length },
|
|
||||||
{ id: "seo", name: "SEO & Content", icon: BarChart3, count: templates.filter((t) => t.category === "seo").length },
|
// Add "All Templates" category
|
||||||
]
|
categoryMap.set("all", { name: "All Templates", icon: Globe, count: templates.length })
|
||||||
|
|
||||||
|
// Add categories from templates
|
||||||
|
templates.forEach(template => {
|
||||||
|
if (!categoryMap.has(template.category)) {
|
||||||
|
// Choose icon based on category name
|
||||||
|
let icon = Code // default
|
||||||
|
if (template.category.toLowerCase().includes('marketing') || template.category.toLowerCase().includes('branding')) {
|
||||||
|
icon = Zap
|
||||||
|
} else if (template.category.toLowerCase().includes('seo') || template.category.toLowerCase().includes('content')) {
|
||||||
|
icon = BarChart3
|
||||||
|
} else if (template.category.toLowerCase().includes('food') || template.category.toLowerCase().includes('delivery')) {
|
||||||
|
icon = Users
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryMap.set(template.category, {
|
||||||
|
name: template.category,
|
||||||
|
icon,
|
||||||
|
count: templates.filter(t => t.category === template.category).length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(categoryMap.entries()).map(([id, data]) => ({
|
||||||
|
id,
|
||||||
|
...data
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = getCategories()
|
||||||
|
|
||||||
const filteredTemplates = templates.filter((template) => {
|
const filteredTemplates = templates.filter((template) => {
|
||||||
const matchesCategory = selectedCategory === "all" || template.category === selectedCategory
|
const matchesCategory = selectedCategory === "all" || template.category === selectedCategory
|
||||||
@ -91,6 +114,127 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
return "Complex"
|
return "Complex"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateTemplate = async (templateData: Partial<DatabaseTemplate>) => {
|
||||||
|
try {
|
||||||
|
await createTemplate(templateData)
|
||||||
|
setShowCustomForm(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating template:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>) => {
|
||||||
|
try {
|
||||||
|
await updateTemplate(id, templateData)
|
||||||
|
setEditingTemplate(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating template:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async () => {
|
||||||
|
if (!deletingTemplate) return
|
||||||
|
|
||||||
|
setDeleteLoading(true)
|
||||||
|
try {
|
||||||
|
await deleteTemplate(deletingTemplate.id)
|
||||||
|
setDeletingTemplate(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting template:', error)
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h1 className="text-4xl font-bold text-white">Loading Templates...</h1>
|
||||||
|
<p className="text-xl text-white/60">Fetching templates from database</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-orange-500"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="flex items-center justify-center space-x-2 text-red-400">
|
||||||
|
<AlertCircle className="h-6 w-6" />
|
||||||
|
<h1 className="text-2xl font-bold">Error Loading Templates</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/60">{error}</p>
|
||||||
|
<Button onClick={() => window.location.reload()} className="bg-orange-500 hover:bg-orange-400 text-black">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show custom template form
|
||||||
|
if (showCustomForm) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h1 className="text-4xl font-bold text-white">Create Custom Template</h1>
|
||||||
|
<p className="text-xl text-white/60">Design your own project template</p>
|
||||||
|
</div>
|
||||||
|
<CustomTemplateForm
|
||||||
|
onSubmit={handleCreateTemplate}
|
||||||
|
onCancel={() => setShowCustomForm(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show edit template form
|
||||||
|
if (editingTemplate) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h1 className="text-4xl font-bold text-white">Edit Template</h1>
|
||||||
|
<p className="text-xl text-white/60">Modify your template settings</p>
|
||||||
|
</div>
|
||||||
|
<EditTemplateForm
|
||||||
|
template={editingTemplate}
|
||||||
|
onSubmit={handleUpdateTemplate}
|
||||||
|
onCancel={() => setEditingTemplate(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show delete confirmation dialog
|
||||||
|
if (deletingTemplate) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h1 className="text-4xl font-bold text-white">Choose Your Project Template</h1>
|
||||||
|
<p className="text-xl text-white/60 max-w-3xl mx-auto">
|
||||||
|
Select from our comprehensive library of professionally designed templates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
templateTitle={deletingTemplate.title}
|
||||||
|
onConfirm={handleDeleteTemplate}
|
||||||
|
onCancel={() => setDeletingTemplate(null)}
|
||||||
|
loading={deleteLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -122,13 +266,13 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
<button
|
<button
|
||||||
key={category.id}
|
key={category.id}
|
||||||
onClick={() => setSelectedCategory(category.id)}
|
onClick={() => setSelectedCategory(category.id)}
|
||||||
className={`flex items-center space-x-3 px-6 py-3 rounded-xl border transition-all ${
|
className={`flex items-center space-x-3 px-6 py-3 rounded-xl border transition-all cursor-pointer ${
|
||||||
active
|
active
|
||||||
? "bg-orange-500 text-black border-orange-500"
|
? "bg-orange-500 text-black border-orange-500"
|
||||||
: "bg-white/5 text-white/80 border-white/10 hover:bg-white/10"
|
: "bg-white/5 text-white/80 border-white/10 hover:bg-white/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`p-2 rounded-lg ${active ? "bg-black" : "bg-white/10"}`}>
|
<div className={`p-2 rounded-lg ${active ? "bg-white/10" : "bg-white/10"}`}>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
@ -163,6 +307,33 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Edit and Delete buttons - only show for database templates */}
|
||||||
|
{template.id && template.id !== "marketing-website" && template.id !== "saas-platform" && (
|
||||||
|
<div className="flex items-center space-x-1 ml-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingTemplate(template as DatabaseTemplate)
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDeletingTemplate(template as DatabaseTemplate)
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0 text-white/60 hover:text-red-400 hover:bg-red-400/10 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/80 text-sm leading-relaxed">{template.description}</p>
|
<p className="text-white/80 text-sm leading-relaxed">{template.description}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -176,7 +347,7 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Layers className="h-4 w-4 text-emerald-400" />
|
<Layers className="h-4 w-4 text-emerald-400" />
|
||||||
<span className="font-medium">{template.features.length} features</span>
|
<span className="font-medium">{(template as any).featureCount ?? template.features.length} features</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -235,7 +406,7 @@ function TemplateSelectionStep({ onNext }: { onNext: (template: Template) => voi
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Template Option */}
|
{/* Custom Template Option */}
|
||||||
<Card className="group border-dashed border-2 border-white/15 bg-white/5 hover:border-white/25 transition-all">
|
<Card className="group border-dashed border-2 border-white/15 bg-white/5 hover:border-white/25 transition-all cursor-pointer" onClick={() => setShowCustomForm(true)}>
|
||||||
<CardContent className="text-center py-16 px-8 text-white/80">
|
<CardContent className="text-center py-16 px-8 text-white/80">
|
||||||
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<Plus className="h-10 w-10 text-orange-400" />
|
<Plus className="h-10 w-10 text-orange-400" />
|
||||||
@ -270,44 +441,132 @@ function FeatureSelectionStep({
|
|||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
}: { template: Template; onNext: () => void; onBack: () => void }) {
|
}: { template: Template; onNext: () => void; onBack: () => void }) {
|
||||||
return (
|
const { fetchFeatures, createFeature, updateFeature, deleteFeature } = useTemplates()
|
||||||
<div className="max-w-7xl mx-auto space-y-8">
|
const [features, setFeatures] = useState<TemplateFeature[]>([])
|
||||||
{/* Header */}
|
const [loading, setLoading] = useState(true)
|
||||||
<div className="text-center space-y-4">
|
const [error, setError] = useState<string | null>(null)
|
||||||
<h1 className="text-4xl font-bold text-white">Select Features for {template.title}</h1>
|
const [newFeature, setNewFeature] = useState({ name: '', description: '', complexity: 'medium' as 'low' | 'medium' | 'high' })
|
||||||
<p className="text-xl text-white/60 max-w-3xl mx-auto">
|
|
||||||
Choose the features that best fit your project requirements.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features List */}
|
const load = async () => {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
try {
|
||||||
{template.features.map((feature, index) => (
|
setLoading(true)
|
||||||
<Card key={index} className="hover:shadow-xl transition-all duration-300 cursor-pointer group border-2 hover:border-orange-200 bg-white/5">
|
const data = await fetchFeatures(template.id)
|
||||||
<CardHeader className="pb-4">
|
setFeatures(data)
|
||||||
<CardTitle className="text-xl group-hover:text-orange-400 transition-colors line-clamp-2 text-white">
|
} catch (e) {
|
||||||
{feature}
|
setError(e instanceof Error ? e.message : 'Failed to load features')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => { load() }, [template.id])
|
||||||
|
|
||||||
|
const handleAddCustom = async () => {
|
||||||
|
if (!newFeature.name.trim()) return
|
||||||
|
await createFeature(template.id, {
|
||||||
|
name: newFeature.name,
|
||||||
|
description: newFeature.description,
|
||||||
|
feature_type: 'custom',
|
||||||
|
complexity: newFeature.complexity,
|
||||||
|
is_default: false,
|
||||||
|
created_by_user: true,
|
||||||
|
})
|
||||||
|
setNewFeature({ name: '', description: '', complexity: 'medium' })
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => {
|
||||||
|
await updateFeature(f.id, { ...updates, isCustom: f.feature_type === 'custom' })
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (f: TemplateFeature) => {
|
||||||
|
await deleteFeature(f.id, { isCustom: f.feature_type === 'custom' })
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = (title: string, list: TemplateFeature[]) => (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-3">{title} ({list.length})</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{list.map((f) => (
|
||||||
|
<Card key={f.id} className="bg-white/5 border-white/10">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-white flex items-center justify-between">
|
||||||
|
<span>{f.name}</span>
|
||||||
|
{f.feature_type === 'custom' && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" className="border-white/20 text-white hover:bg-white/10" onClick={async () => {
|
||||||
|
const newName = window.prompt('Update feature name', f.name || '')
|
||||||
|
if (newName === null) return
|
||||||
|
const newDesc = window.prompt('Update description', f.description || '')
|
||||||
|
await handleUpdate(f, { name: newName, description: newDesc ?? f.description })
|
||||||
|
}}>Edit</Button>
|
||||||
|
<Button size="sm" variant="outline" className="border-red-500 text-red-300 hover:bg-red-500/10" onClick={() => handleDelete(f)}>Delete</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
<CardContent className="text-white/80 text-sm space-y-2">
|
||||||
<CardContent className="space-y-4 text-white/80">
|
<p>{f.description || 'No description provided.'}</p>
|
||||||
<p className="text-white/80 text-sm leading-relaxed">
|
<div className="flex gap-2 text-xs">
|
||||||
This feature enhances your project with {feature} capabilities.
|
<Badge variant="outline" className="bg-white/5 border-white/10">{f.feature_type}</Badge>
|
||||||
</p>
|
<Badge variant="outline" className="bg-white/5 border-white/10">{f.complexity}</Badge>
|
||||||
|
{typeof f.usage_count === 'number' && <Badge variant="outline" className="bg-white/5 border-white/10">used {f.usage_count}</Badge>}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-20 text-white/60">Loading features...</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <div className="text-center py-20 text-red-400">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const essentials = features.filter(f => f.feature_type === 'essential')
|
||||||
|
const suggested = features.filter(f => f.feature_type === 'suggested')
|
||||||
|
const custom = features.filter(f => f.feature_type === 'custom')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-8">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold text-white">Select Features for {template.title}</h1>
|
||||||
|
<p className="text-xl text-white/60 max-w-3xl mx-auto">Choose defaults or add your own custom features.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{section('Essential Features', essentials)}
|
||||||
|
{section('Suggested Features', suggested)}
|
||||||
|
|
||||||
|
{/* Add custom feature */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-4 space-y-3">
|
||||||
|
<h3 className="text-white font-semibold">Add Custom Feature</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<Input placeholder="Feature name" value={newFeature.name} onChange={(e) => setNewFeature({ ...newFeature, name: e.target.value })} className="bg-white/5 border-white/10 text-white placeholder:text-white/40" />
|
||||||
|
<Input placeholder="Description (optional)" value={newFeature.description} onChange={(e) => setNewFeature({ ...newFeature, description: e.target.value })} className="bg-white/5 border-white/10 text-white placeholder:text-white/40" />
|
||||||
|
<select value={newFeature.complexity} onChange={(e) => setNewFeature({ ...newFeature, complexity: e.target.value as any })} className="bg-white/5 border-white/10 text-white rounded-md px-3">
|
||||||
|
<option value="low">low</option>
|
||||||
|
<option value="medium">medium</option>
|
||||||
|
<option value="high">high</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-white/60 text-sm">Custom features are saved to this template.</div>
|
||||||
|
<Button onClick={handleAddCustom} className="bg-orange-500 hover:bg-orange-400 text-black">Add Feature</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{section('Your Custom Features', custom)}
|
||||||
|
|
||||||
{/* Navigation Buttons */}
|
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<div className="space-x-4">
|
<div className="space-x-4">
|
||||||
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10">
|
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10">Back</Button>
|
||||||
Back
|
<Button onClick={onNext} className="bg-orange-500 hover:bg-orange-400 text-black font-semibold py-2 rounded-lg shadow">Continue</Button>
|
||||||
</Button>
|
|
||||||
<Button onClick={onNext} className="bg-orange-500 hover:bg-orange-400 text-black font-semibold py-2 rounded-lg shadow">
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import Link from "next/link"
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -12,10 +12,10 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Bell, Settings, LogOut, User, Menu, X } from "lucide-react"
|
import { Bell, Settings, LogOut, User, Menu, X } from "lucide-react";
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Project Builder", href: "/", current: false },
|
{ name: "Project Builder", href: "/", current: false },
|
||||||
@ -23,12 +23,30 @@ const navigation = [
|
|||||||
{ name: "Features", href: "/features", current: false },
|
{ name: "Features", href: "/features", current: false },
|
||||||
{ name: "Business Context", href: "/business-context", current: false },
|
{ name: "Business Context", href: "/business-context", current: false },
|
||||||
{ name: "Architecture", href: "/architecture", current: false },
|
{ name: "Architecture", href: "/architecture", current: false },
|
||||||
]
|
];
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const pathname = usePathname()
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
const { user, logout } = useAuth()
|
const pathname = usePathname();
|
||||||
|
const { user, logout, isLoading } = useAuth();
|
||||||
|
|
||||||
|
// Handle logout with loading state
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
await logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error);
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define routes where the header should not be shown
|
||||||
|
const noHeaderRoutes = ["/signin", "/signup"];
|
||||||
|
if (noHeaderRoutes.includes(pathname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-black/80 text-white border-b border-white/10 backdrop-blur">
|
<header className="bg-black/80 text-white border-b border-white/10 backdrop-blur">
|
||||||
@ -63,9 +81,9 @@ export default function Header() {
|
|||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center space-x-4 cursor-pointer">
|
<div className="flex items-center space-x-4 cursor-pointer">
|
||||||
{/* Auth Button or User Menu */}
|
{/* While loading, don't show sign-in or user menu to avoid flicker */}
|
||||||
{!user ? (
|
{!isLoading && (!user ? (
|
||||||
<Link href="/auth">
|
<Link href="/signin">
|
||||||
<Button size="sm" className="cursor-pointer">Sign In</Button>
|
<Button size="sm" className="cursor-pointer">Sign In</Button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@ -78,24 +96,26 @@ export default function Header() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{/* User Menu - Only show when user is authenticated */}
|
{/* User Menu - Only show when user is authenticated */}
|
||||||
{user && (
|
{!isLoading && user && user.email && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full cursor-pointer hover:bg-white/5">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src={user.avatar || "/avatars/01.png"} alt={user.name} />
|
<AvatarImage src={user.avatar || "/avatars/01.png"} alt={user.name || user.email || "User"} />
|
||||||
<AvatarFallback>{user.name.charAt(0).toUpperCase()}</AvatarFallback>
|
<AvatarFallback>
|
||||||
|
{(user.name && user.name.charAt(0)) || (user.email && user.email.charAt(0)) || "U"}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
<DropdownMenuContent className="w-56 bg-black text-white border-white/10" align="end" forceMount>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
<p className="text-sm font-medium leading-none">{user.name || user.email || "User"}</p>
|
||||||
<p className="text-xs leading-none text-white/60">{user.email}</p>
|
<p className="text-xs leading-none text-white/60">{user.email || "No email"}</p>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@ -108,9 +128,9 @@ export default function Header() {
|
|||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={logout} className="hover:bg-white/5 cursor-pointer">
|
<DropdownMenuItem onClick={handleLogout} className="hover:bg-white/5 cursor-pointer" disabled={isLoggingOut}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>Log out</span>
|
<span>{isLoggingOut ? 'Logging out...' : 'Log out'}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@ -146,5 +166,5 @@ export default function Header() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from "react"
|
||||||
|
import { logout as apiLogout } from "@/components/apis/authApiClients"
|
||||||
|
import { safeLocalStorage } from "@/lib/utils"
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string
|
id: string
|
||||||
@ -15,7 +17,8 @@ interface AuthContextType {
|
|||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
login: (email: string, password: string) => Promise<boolean>
|
login: (email: string, password: string) => Promise<boolean>
|
||||||
signup: (name: string, email: string, password: string) => Promise<boolean>
|
signup: (name: string, email: string, password: string) => Promise<boolean>
|
||||||
logout: () => void
|
logout: () => Promise<void>
|
||||||
|
setUserFromApi: (apiUser: any) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||||
@ -40,14 +43,21 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuth = () => {
|
const checkAuth = () => {
|
||||||
// Check localStorage for user data
|
// Check localStorage for user data
|
||||||
const userData = localStorage.getItem("codenuk_user")
|
const userData = safeLocalStorage.getItem("codenuk_user")
|
||||||
if (userData) {
|
if (userData) {
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(userData)
|
const user = JSON.parse(userData)
|
||||||
setUser(user)
|
// Ensure user object has all required properties with fallbacks
|
||||||
|
const validatedUser: User = {
|
||||||
|
id: user.id || "1",
|
||||||
|
name: user.name || user.email?.split("@")[0] || "User",
|
||||||
|
email: user.email || "user@example.com",
|
||||||
|
avatar: user.avatar || "/avatars/01.png"
|
||||||
|
}
|
||||||
|
setUser(validatedUser)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing user data:", error)
|
console.error("Error parsing user data:", error)
|
||||||
localStorage.removeItem("codenuk_user")
|
safeLocalStorage.removeItem("codenuk_user")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@ -56,6 +66,22 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
checkAuth()
|
checkAuth()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Allow setting user right after successful API login
|
||||||
|
const setUserFromApi = (apiUser: any) => {
|
||||||
|
const validatedUser: User = {
|
||||||
|
id: (apiUser.id || apiUser.user_id || "1").toString(),
|
||||||
|
name:
|
||||||
|
apiUser.name ||
|
||||||
|
[apiUser.first_name, apiUser.last_name].filter(Boolean).join(" ") ||
|
||||||
|
apiUser.username ||
|
||||||
|
(apiUser.email ? apiUser.email.split("@")[0] : "User"),
|
||||||
|
email: apiUser.email || "user@example.com",
|
||||||
|
avatar: apiUser.avatar || "/avatars/01.png",
|
||||||
|
}
|
||||||
|
setUser(validatedUser)
|
||||||
|
safeLocalStorage.setItem("codenuk_user", JSON.stringify(validatedUser))
|
||||||
|
}
|
||||||
|
|
||||||
const login = async (email: string, password: string): Promise<boolean> => {
|
const login = async (email: string, password: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@ -73,7 +99,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUser(user)
|
setUser(user)
|
||||||
localStorage.setItem("codenuk_user", JSON.stringify(user))
|
safeLocalStorage.setItem("codenuk_user", JSON.stringify(user))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,7 +129,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUser(user)
|
setUser(user)
|
||||||
localStorage.setItem("codenuk_user", JSON.stringify(user))
|
safeLocalStorage.setItem("codenuk_user", JSON.stringify(user))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,9 +142,16 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async (): Promise<void> => {
|
||||||
setUser(null)
|
try {
|
||||||
localStorage.removeItem("codenuk_user")
|
// Call the API logout function which handles backend call, token clearing, and redirect
|
||||||
|
await apiLogout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error);
|
||||||
|
// Even if there's an error, clear local state
|
||||||
|
setUser(null);
|
||||||
|
safeLocalStorage.removeItem("codenuk_user");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: AuthContextType = {
|
const value: AuthContextType = {
|
||||||
@ -127,7 +160,8 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
logout
|
logout,
|
||||||
|
setUserFromApi
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
138
src/hooks/useTemplates.ts
Normal file
138
src/hooks/useTemplates.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { templateService, DatabaseTemplate, TemplatesByCategory, TemplateFeature } from '@/lib/template-service'
|
||||||
|
|
||||||
|
export function useTemplates() {
|
||||||
|
const [templates, setTemplates] = useState<TemplatesByCategory>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const data = await templateService.getTemplatesByCategory()
|
||||||
|
setTemplates(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch templates')
|
||||||
|
console.error('Error fetching templates:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplates()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createTemplate = async (templateData: Partial<DatabaseTemplate>) => {
|
||||||
|
try {
|
||||||
|
const newTemplate = await templateService.createTemplate(templateData)
|
||||||
|
// Refresh templates after creating a new one
|
||||||
|
await fetchTemplates()
|
||||||
|
return newTemplate
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create template')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTemplate = async (id: string, templateData: Partial<DatabaseTemplate>) => {
|
||||||
|
try {
|
||||||
|
const updatedTemplate = await templateService.updateTemplate(id, templateData)
|
||||||
|
// Refresh templates after updating
|
||||||
|
await fetchTemplates()
|
||||||
|
return updatedTemplate
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to update template')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTemplate = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await templateService.deleteTemplate(id)
|
||||||
|
// Refresh templates after deleting
|
||||||
|
await fetchTemplates()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete template')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert database templates to the format expected by the UI
|
||||||
|
const getTemplatesForUI = () => {
|
||||||
|
const allTemplates: Array<DatabaseTemplate & {
|
||||||
|
features: string[]
|
||||||
|
complexity: number
|
||||||
|
timeEstimate: string
|
||||||
|
techStack: string[]
|
||||||
|
popularity?: number
|
||||||
|
lastUpdated?: string
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
Object.entries(templates).forEach(([category, categoryTemplates]) => {
|
||||||
|
categoryTemplates.forEach((template) => {
|
||||||
|
// Convert database template to UI format
|
||||||
|
const uiTemplate = {
|
||||||
|
...template,
|
||||||
|
features: [], // Will be populated when template is selected
|
||||||
|
complexity: 3, // Default complexity
|
||||||
|
timeEstimate: "2-4 weeks", // Default time estimate
|
||||||
|
techStack: ["Next.js", "PostgreSQL", "Tailwind CSS"], // Default tech stack
|
||||||
|
popularity: template.avg_rating ? Math.round(template.avg_rating * 20) : 75, // Convert rating to popularity
|
||||||
|
lastUpdated: template.updated_at ? new Date(template.updated_at).toISOString().split('T')[0] : undefined,
|
||||||
|
featureCount: (template as any).feature_count ?? 0
|
||||||
|
}
|
||||||
|
allTemplates.push(uiTemplate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return allTemplates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get template features when a template is selected
|
||||||
|
const getTemplateFeatures = async (templateId: string): Promise<string[]> => {
|
||||||
|
try {
|
||||||
|
const features = await templateService.getFeaturesForTemplate(templateId)
|
||||||
|
return features.map(feature => feature.name)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching template features:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature CRUD for a template
|
||||||
|
const fetchFeatures = async (templateId: string): Promise<TemplateFeature[]> => {
|
||||||
|
return templateService.getFeaturesForTemplate(templateId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createFeature = async (templateId: string, feature: Partial<TemplateFeature>) => {
|
||||||
|
const payload = { ...feature, template_id: templateId }
|
||||||
|
return templateService.createFeature(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFeature = async (featureId: string, updates: Partial<TemplateFeature>) => {
|
||||||
|
// If updates indicate custom, route accordingly
|
||||||
|
return templateService.updateFeature(featureId, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFeature = async (featureId: string, opts?: { isCustom?: boolean }) => {
|
||||||
|
return templateService.deleteFeature(featureId, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchTemplates,
|
||||||
|
createTemplate,
|
||||||
|
updateTemplate,
|
||||||
|
deleteTemplate,
|
||||||
|
getTemplatesForUI,
|
||||||
|
getTemplateFeatures,
|
||||||
|
fetchFeatures,
|
||||||
|
createFeature,
|
||||||
|
updateFeature,
|
||||||
|
deleteFeature
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/lib/template-service.ts
Normal file
232
src/lib/template-service.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
export interface DatabaseTemplate {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: string | null
|
||||||
|
category: string
|
||||||
|
gradient: string | null
|
||||||
|
border: string | null
|
||||||
|
text: string | null
|
||||||
|
subtext: string | null
|
||||||
|
is_active: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
feature_count?: number
|
||||||
|
avg_rating?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateFeature {
|
||||||
|
id: string
|
||||||
|
template_id: string
|
||||||
|
feature_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
feature_type: 'essential' | 'suggested' | 'custom'
|
||||||
|
complexity: 'low' | 'medium' | 'high'
|
||||||
|
display_order: number
|
||||||
|
usage_count: number
|
||||||
|
user_rating: number
|
||||||
|
is_default: boolean
|
||||||
|
created_by_user: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateWithFeatures extends DatabaseTemplate {
|
||||||
|
features: TemplateFeature[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplatesByCategory {
|
||||||
|
[category: string]: DatabaseTemplate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost:8009'
|
||||||
|
|
||||||
|
class TemplateService {
|
||||||
|
private async makeRequest<T>(endpoint: string): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || 'API request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Template service error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplatesByCategory(): Promise<TemplatesByCategory> {
|
||||||
|
return this.makeRequest<TemplatesByCategory>('/api/templates')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplateById(id: string): Promise<TemplateWithFeatures> {
|
||||||
|
return this.makeRequest<TemplateWithFeatures>(`/api/templates/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTemplateByType(type: string): Promise<TemplateWithFeatures> {
|
||||||
|
return this.makeRequest<TemplateWithFeatures>(`/api/templates/type/${type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTemplate(templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/templates`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(templateData),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || 'Failed to create template')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create template error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features API
|
||||||
|
async getFeaturesForTemplate(templateId: string): Promise<TemplateFeature[]> {
|
||||||
|
// Use merged endpoint to include custom features
|
||||||
|
try {
|
||||||
|
return await this.makeRequest<TemplateFeature[]>(`/api/features/templates/${templateId}/merged`)
|
||||||
|
} catch {
|
||||||
|
// Fallback to default-only if merged endpoint unsupported
|
||||||
|
return this.makeRequest<TemplateFeature[]>(`/api/templates/${templateId}/features`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchFeatures(searchTerm: string, templateId?: string): Promise<TemplateFeature[]> {
|
||||||
|
const q = encodeURIComponent(searchTerm)
|
||||||
|
const extra = templateId ? `&template_id=${encodeURIComponent(templateId)}` : ''
|
||||||
|
return this.makeRequest<TemplateFeature[]>(`/api/features/search?q=${q}${extra}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFeature(featureData: Partial<TemplateFeature>): Promise<TemplateFeature> {
|
||||||
|
if (
|
||||||
|
featureData &&
|
||||||
|
(featureData.feature_type === 'custom' || (featureData as any).feature_type === 'custom')
|
||||||
|
) {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/features/custom`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(featureData),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) throw new Error(data.message || 'Failed to create custom feature')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/features`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(featureData),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) throw new Error(data.message || 'Failed to create feature')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFeature(id: string, featureData: Partial<TemplateFeature> & { isCustom?: boolean }): Promise<TemplateFeature> {
|
||||||
|
const isCustom = (featureData as any).isCustom || featureData.feature_type === 'custom'
|
||||||
|
const url = isCustom ? `${API_BASE_URL}/api/features/custom/${id}` : `${API_BASE_URL}/api/features/${id}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(featureData),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) throw new Error(data.message || 'Failed to update feature')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFeature(id: string, opts?: { isCustom?: boolean }): Promise<void> {
|
||||||
|
const url = opts?.isCustom ? `${API_BASE_URL}/api/features/custom/${id}` : `${API_BASE_URL}/api/features/${id}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) throw new Error(data.message || 'Failed to delete feature')
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTemplate(id: string, templateData: Partial<DatabaseTemplate>): Promise<DatabaseTemplate> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(templateData),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || 'Failed to update template')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update template error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTemplate(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/templates/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || 'Failed to delete template')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete template error:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const templateService = new TemplateService()
|
||||||
@ -1,6 +1,33 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safe redirect function that works in both client and server environments
|
||||||
|
export const safeRedirect = (url: string) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safe localStorage functions
|
||||||
|
export const safeLocalStorage = {
|
||||||
|
getItem: (key: string): string | null => {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) {
|
||||||
|
return window.localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
setItem: (key: string, value: string): void => {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) {
|
||||||
|
window.localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeItem: (key: string): void => {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user