Compare commits
10 Commits
5026a20b16
...
46f24c10fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 46f24c10fe | |||
| a26ca40a5b | |||
| 5f373cf006 | |||
| 75fd1e407a | |||
| 68a100c1e9 | |||
| 845234ace8 | |||
| 1b5a1f31ba | |||
| 5cc674de8d | |||
| fd846acb6e | |||
| c69e5a8c6a |
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git restore:*)",
|
||||
"Bash(npm run lint)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(npm run build:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,9 +1,9 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
deploy
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
downloaded_images
|
||||
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
|
||||
14
.kilocode/mcp.json
Normal file
14
.kilocode/mcp.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@upstash/context7-mcp"
|
||||
],
|
||||
"env": {
|
||||
"DEFAULT_MINIMUM_TOKENS": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
135
CLAUDE.md
Normal file
135
CLAUDE.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Build and Development
|
||||
- `npm run dev` - Start development server with Turbopack
|
||||
- `npm run build` - Build production version
|
||||
- `npm run start` - Start production server
|
||||
- `npm run lint` - Run ESLint linter
|
||||
- `npm run knip` - Run Knip unused code analysis
|
||||
|
||||
### Testing Commands (Scripts Directory)
|
||||
- `npm test` - Run all tests
|
||||
- `npm run test-categories` - Test poster ad categories
|
||||
- `npm run test-video-categories` - Test video ad categories
|
||||
- `npm run test-line-categories` - Test line ad categories
|
||||
- `npm run test-images` - Test image upload functionality
|
||||
- `npm run test-videos` - Test video upload functionality
|
||||
- `npm run test-positions` - Test position enum values
|
||||
- `npm run fetch-categories` - Fetch current categories from API
|
||||
|
||||
### Data Creation Scripts
|
||||
- `npm run start` or `node add-poster-ads.js` - Create 8 poster ads
|
||||
- `npm run start-video` or `node add-video-ads.js` - Create 8 video ads
|
||||
- `npm run start-line` or `node add-line-ads.js` - Create 20 line ads
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Application Structure
|
||||
PaisaAds is a classified ads platform built with Next.js 15, featuring three main user roles and three types of advertisements.
|
||||
|
||||
### User Roles & Authentication
|
||||
- **USER**: Regular users who can post and manage ads
|
||||
- **ADMIN ROLES**: SUPER_ADMIN, EDITOR, REVIEWER, VIEWER for management functions
|
||||
- JWT-based authentication with role-based route protection via middleware
|
||||
|
||||
### Route Structure
|
||||
- **Public Routes**: `/` (redirects to `/search` if authenticated)
|
||||
- **User Dashboard**: `/dashboard/*` - Ad management, profile, posting ads
|
||||
- **Admin Dashboard**: `/mgmt/dashboard/*` - User management, ad review, reports
|
||||
- **Search**: `/search/*` - Browse and search ads
|
||||
|
||||
### Advertisement Types
|
||||
1. **Poster Ads** - Single image advertisements
|
||||
2. **Video Ads** - Single video with position-based placement (8 positions)
|
||||
3. **Line Ads** - Rich text content with 1-3 images and contact information
|
||||
|
||||
### Key Technical Components
|
||||
|
||||
#### Authentication & Authorization
|
||||
- JWT tokens stored in httpOnly cookies
|
||||
- Middleware at `src/middleware.ts` handles route protection
|
||||
- Role-based access control for admin functions
|
||||
|
||||
#### Data Management
|
||||
- Axios-based API client with automatic credential handling
|
||||
- React Query for server state management
|
||||
- Form handling with React Hook Form and Zod validation
|
||||
|
||||
#### UI Framework
|
||||
- Tailwind CSS for styling
|
||||
- Radix UI components for accessible UI elements
|
||||
- Lucide React for icons
|
||||
- Next.js App Router for routing
|
||||
|
||||
#### File Management
|
||||
- Image upload via `/api/images/route.ts`
|
||||
- Support for poster images, video files, and line ad galleries
|
||||
- Sharp for image processing in scripts
|
||||
|
||||
### Category System
|
||||
Hierarchical category structure:
|
||||
- Main Category → Category One → Category Two → Category Three
|
||||
- Categories are validated against server data
|
||||
- Used across all ad types for organization
|
||||
|
||||
### Development Patterns
|
||||
|
||||
#### Component Organization
|
||||
- Page components in `src/app/` following Next.js App Router conventions
|
||||
- Reusable UI components in `src/components/ui/`
|
||||
- Form components in `src/components/forms/`
|
||||
- Management components in `src/components/mgmt/`
|
||||
|
||||
#### Data Types
|
||||
- TypeScript interfaces in `src/lib/types/`
|
||||
- Enums in `src/lib/enum/`
|
||||
- Zod schemas in `src/lib/validations.ts`
|
||||
|
||||
#### State Management
|
||||
- React Query for server state
|
||||
- Local state with React hooks
|
||||
- Context providers in `src/app/root_provider.tsx`
|
||||
|
||||
### API Integration
|
||||
- Backend API base URL: `http://localhost:3001/server/` (development)
|
||||
- Endpoints for authentication, categories, file upload, and CRUD operations
|
||||
- Automatic credential handling with withCredentials: true
|
||||
|
||||
### Development Scripts
|
||||
Comprehensive scripts in `/scripts/` directory for:
|
||||
- Creating sample data (ads, categories)
|
||||
- Testing API endpoints
|
||||
- Image/video processing and validation
|
||||
- Backend integration testing
|
||||
|
||||
### Environment Configuration
|
||||
- Development server runs on port 3000
|
||||
- API server expected on port 3001
|
||||
- JWT_SECRET required for authentication
|
||||
- Images unoptimized in Next.js config for compatibility
|
||||
|
||||
## Common Development Workflows
|
||||
|
||||
### Adding New Ad Types
|
||||
1. Create type definitions in `src/lib/types/`
|
||||
2. Add validation schemas in `src/lib/validations.ts`
|
||||
3. Create form components in `src/components/forms/`
|
||||
4. Add routes in appropriate dashboard sections
|
||||
5. Update middleware if needed for route protection
|
||||
|
||||
### Testing API Integration
|
||||
Use scripts in `/scripts/` directory to test:
|
||||
- Authentication flow
|
||||
- Category validation
|
||||
- File upload processes
|
||||
- Ad creation workflows
|
||||
|
||||
### Form Development
|
||||
- Use React Hook Form with Zod resolvers
|
||||
- Follow existing form patterns in `src/components/forms/`
|
||||
- Include proper error handling and validation feedback
|
||||
- Integrate with API endpoints via axios client
|
||||
437
CONFIG.md
Normal file
437
CONFIG.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# Configuration Management System
|
||||
|
||||
This document provides comprehensive information about the MongoDB-based configuration system in the Paisa Ads backend application.
|
||||
|
||||
## Overview
|
||||
|
||||
The configuration system manages dynamic application settings that can be updated without code deployment. All configurations are stored in MongoDB and provide REST API endpoints for CRUD operations.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Technology Stack
|
||||
- **Database**: MongoDB (secondary database)
|
||||
- **ODM**: Mongoose for MongoDB interactions
|
||||
- **Validation**: class-validator with DTOs
|
||||
- **Authorization**: Role-based access control (SUPER_ADMIN, EDITOR)
|
||||
- **Documentation**: Swagger/OpenAPI integration
|
||||
|
||||
### Module Structure
|
||||
```
|
||||
src/configurations/
|
||||
├── configurations.controller.ts # REST API endpoints
|
||||
├── configurations.service.ts # Business logic and MongoDB operations
|
||||
├── configurations.module.ts # Module configuration and dependencies
|
||||
├── dto/ # Data Transfer Objects with validation
|
||||
│ ├── ad-pricing.dto.ts
|
||||
│ ├── privacy-policy.dto.ts
|
||||
│ ├── search-slogan.dto.ts
|
||||
│ ├── about-us.dto.ts
|
||||
│ ├── faq.dto.ts
|
||||
│ ├── contact-page.dto.ts
|
||||
│ └── terms-and-conditions.ts
|
||||
└── schemas/ # MongoDB schemas with Mongoose
|
||||
├── ad-pricing.schema.ts
|
||||
├── privacy-policy.schema.ts
|
||||
├── search-slogan.schema.ts
|
||||
├── about-us.schema.ts
|
||||
├── faq.schema.ts
|
||||
├── contact-page.schema.ts
|
||||
└── terms-and-conditions.schema.ts
|
||||
```
|
||||
|
||||
## Configuration Types
|
||||
|
||||
### 1. Ad Pricing Configuration
|
||||
**Endpoint**: `/server/configurations/ad-pricing`
|
||||
|
||||
Manages pricing for different ad types with historical tracking.
|
||||
|
||||
**Schema Fields**:
|
||||
- `lineAdPrice`: Price for line/text ads
|
||||
- `posterAdPrice`: Price for poster/image ads
|
||||
- `videoAdPrice`: Price for video ads
|
||||
- `currency`: Currency code (default: "NPR")
|
||||
- `isActive`: Active status flag
|
||||
- `lastUpdated`: Timestamp of last update
|
||||
- `updatedBy`: User who made the update
|
||||
|
||||
**API Endpoints**:
|
||||
- `POST /ad-pricing` - Create/update pricing (SUPER_ADMIN only)
|
||||
- `GET /ad-pricing` - Get current pricing (Public)
|
||||
- `GET /ad-pricing/history` - Get pricing history (SUPER_ADMIN, EDITOR)
|
||||
|
||||
### 2. Privacy Policy Configuration
|
||||
**Endpoint**: `/server/configurations/privacy-policy`
|
||||
|
||||
Manages privacy policy content with version control.
|
||||
|
||||
**Schema Fields**:
|
||||
- `content`: Privacy policy HTML/markdown content
|
||||
- `version`: Version number for tracking changes
|
||||
- `effectiveDate`: When the policy becomes effective
|
||||
- `isActive`: Active status flag
|
||||
- `lastUpdated`: Timestamp of last update
|
||||
- `updatedBy`: User who made the update
|
||||
|
||||
**API Endpoints**:
|
||||
- `POST /privacy-policy` - Create/update policy (SUPER_ADMIN only)
|
||||
- `GET /privacy-policy` - Get current policy (Public)
|
||||
- `GET /privacy-policy/history` - Get policy history (SUPER_ADMIN, EDITOR)
|
||||
|
||||
### 3. Search Page Slogan Configuration
|
||||
**Endpoint**: `/server/configurations/search-slogan`
|
||||
|
||||
Manages dynamic slogans displayed on the search page.
|
||||
|
||||
**Schema Fields**:
|
||||
- `primarySlogan`: Main slogan text
|
||||
- `secondarySlogan`: Optional secondary slogan
|
||||
- `isActive`: Active status flag
|
||||
- `lastUpdated`: Timestamp of last update
|
||||
- `updatedBy`: User who made the update
|
||||
|
||||
**API Endpoints**:
|
||||
- `POST /search-slogan` - Create/update slogan (SUPER_ADMIN, EDITOR)
|
||||
- `GET /search-slogan` - Get current slogan (Public)
|
||||
|
||||
### 4. About Us Page Configuration
|
||||
**Endpoint**: `/server/configurations/about-us`
|
||||
|
||||
Comprehensive about us page content management.
|
||||
|
||||
**Schema Fields**:
|
||||
- `companyOverview`: Company description
|
||||
- `mission`: Mission statement
|
||||
- `vision`: Vision statement
|
||||
- `values`: Array of company values
|
||||
- `history`: Company history content
|
||||
- `teamMembers`: Array of team member objects
|
||||
- `name`: Team member name
|
||||
- `position`: Job title/position
|
||||
- `bio`: Biography
|
||||
- `imageUrl`: Profile image URL
|
||||
- `socialLinks`: Social media links
|
||||
- `achievements`: Array of company achievements
|
||||
- `contactInfo`: Contact information
|
||||
- `isActive`: Active status flag
|
||||
- `lastUpdated`: Timestamp of last update
|
||||
- `updatedBy`: User who made the update
|
||||
|
||||
**API Endpoints**:
|
||||
- `POST /about-us` - Create/update about page (SUPER_ADMIN, EDITOR)
|
||||
- `GET /about-us` - Get about us content (Public)
|
||||
|
||||
### 5. FAQ Configuration
|
||||
**Endpoint**: `/server/configurations/faq`
|
||||
|
||||
Dynamic FAQ management with categorization and ordering.
|
||||
|
||||
**Schema Fields**:
|
||||
- `questions`: Array of FAQ questions
|
||||
- `question`: Question text
|
||||
- `answer`: Answer text
|
||||
- `category`: Question category
|
||||
- `order`: Display order
|
||||
- `isActive`: Active status for the question
|
||||
- `categories`: Array of available categories
|
||||
- `introduction`: FAQ page introduction text
|
||||
- `contactInfo`: Additional help contact information
|
||||
- `isActive`: Active status flag
|
||||
- `lastUpdated`: Timestamp of last update
|
||||
- `updatedBy`: User who made the update
|
||||
|
||||
**API Endpoints**:
|
||||
- `POST /faq` - Create/update FAQ (SUPER_ADMIN, EDITOR)
|
||||
- `GET /faq` - Get all FAQ content (Public)
|
||||
- `GET /faq/category/:category` - Get FAQ by category (Public)
|
||||
- `POST /faq/question` - Add new FAQ question (SUPER_ADMIN, EDITOR)
|
||||
- `PATCH /faq/question/:index` - Update specific question (SUPER_ADMIN, EDITOR)
|
||||
|
||||
### 6. Contact Page Configuration
|
||||
**Endpoint**: `/server/configurations/contact-page`
|
||||
|
||||
Complete contact information management.
|
||||
|
||||
**Schema Fields**:
|
||||
- `companyName`: Company name
|
||||
- `email`: Primary email address
|
||||
- `phone`: Primary phone number
|
||||
- `alternatePhone`: Alternate phone number
|
||||
- `address`: Complete address
|
||||
- `city`, `state`, `postalCode`, `country`: Address components
|
||||
- `coordinates`: GPS coordinates object
|
||||
- `latitude`: Latitude coordinate
|
||||
- `longitude`: Longitude coordinate
|
||||
- `socialMediaLinks`: Array of social media URLs
|
||||
- `businessHours`: Business hours object
|
||||
- `monday` through `sunday`: Hours for each day
|
||||
- `supportEmail`: Support-specific email
|
||||
- `salesEmail`: Sales-specific email
|
||||
- `emergencyContact`: Emergency contact information
|
||||
- `websiteUrl`: Company website URL
|
||||
- `isActive`: Active status flag
|
||||
- `lastUpdated`: Timestamp of last update
|
||||
- `updatedBy`: User who made the update
|
||||
|
||||
**API Endpoints**:
|
||||
- `POST /contact-page` - Create/update contact info (SUPER_ADMIN, EDITOR)
|
||||
- `GET /contact-page` - Get contact information (Public)
|
||||
|
||||
### 7. Terms and Conditions Configuration
|
||||
**Endpoint**: `/server/configurations/terms-and-conditions`
|
||||
|
||||
Legacy terms and conditions management (existing functionality).
|
||||
|
||||
**API Endpoints**:
|
||||
- `POST /terms-and-conditions` - Create/update terms (SUPER_ADMIN only)
|
||||
- `GET /terms-and-conditions` - Get current terms (Public)
|
||||
|
||||
## Authorization Matrix
|
||||
|
||||
| Endpoint | SUPER_ADMIN | EDITOR | Public |
|
||||
|----------|-------------|--------|--------|
|
||||
| POST /ad-pricing | ✅ | ❌ | ❌ |
|
||||
| POST /privacy-policy | ✅ | ❌ | ❌ |
|
||||
| POST /terms-and-conditions | ✅ | ❌ | ❌ |
|
||||
| POST /search-slogan | ✅ | ✅ | ❌ |
|
||||
| POST /about-us | ✅ | ✅ | ❌ |
|
||||
| POST /faq | ✅ | ✅ | ❌ |
|
||||
| POST /contact-page | ✅ | ✅ | ❌ |
|
||||
| GET /*/history | ✅ | ✅ | ❌ |
|
||||
| GET /* (all read operations) | ✅ | ✅ | ✅ |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating Ad Pricing Configuration
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/server/configurations/ad-pricing \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"lineAdPrice": 100,
|
||||
"posterAdPrice": 500,
|
||||
"videoAdPrice": 1000,
|
||||
"currency": "NPR",
|
||||
"updatedBy": "admin@paisaads.com"
|
||||
}'
|
||||
```
|
||||
|
||||
### Getting Current Configuration
|
||||
```bash
|
||||
curl http://localhost:3001/server/configurations/ad-pricing
|
||||
```
|
||||
|
||||
### Adding FAQ Question
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/server/configurations/faq/question \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"question": "How do I create an ad?",
|
||||
"answer": "To create an ad, go to the dashboard and click Create Ad...",
|
||||
"category": "General"
|
||||
}'
|
||||
```
|
||||
|
||||
### Getting All Configurations
|
||||
```bash
|
||||
curl http://localhost:3001/server/configurations/all
|
||||
```
|
||||
|
||||
## Database Design Patterns
|
||||
|
||||
### 1. Upsert Pattern
|
||||
All configuration updates use MongoDB's `findOneAndUpdate` with upsert option to ensure single document per configuration type.
|
||||
|
||||
### 2. Soft Delete Pattern
|
||||
Configurations use `isActive` flags instead of hard deletes to maintain data integrity and audit trails.
|
||||
|
||||
### 3. Historical Tracking
|
||||
Pricing and policy configurations maintain historical records for audit and rollback capabilities.
|
||||
|
||||
### 4. Nested Document Structure
|
||||
Complex configurations (About Us, FAQ, Contact) use nested documents for structured data organization.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Service Layer Architecture
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class ConfigurationsService {
|
||||
constructor(
|
||||
@InjectModel(AdPricing.name) private adPricingModel: Model<AdPricing>,
|
||||
@InjectModel(PrivacyPolicy.name) private privacyPolicyModel: Model<PrivacyPolicy>,
|
||||
// ... other model injections
|
||||
) {}
|
||||
|
||||
async createOrUpdateAdPricing(adPricingDto: AdPricingDto) {
|
||||
return await this.adPricingModel.findOneAndUpdate(
|
||||
{},
|
||||
{ ...adPricingDto, lastUpdated: new Date() },
|
||||
{ new: true, upsert: true, setDefaultsOnInsert: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### DTO Validation Example
|
||||
```typescript
|
||||
export class AdPricingDto {
|
||||
@ApiProperty({ description: 'Price for line ads', example: 100 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
lineAdPrice: number;
|
||||
|
||||
@ApiProperty({ description: 'Currency code', example: 'NPR' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Schema Definition Example
|
||||
```typescript
|
||||
@Schema({ timestamps: true })
|
||||
export class AdPricing {
|
||||
@Prop({ required: true, min: 0 })
|
||||
lineAdPrice: number;
|
||||
|
||||
@Prop({ default: 'NPR' })
|
||||
currency: string;
|
||||
|
||||
@Prop({ default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Prop()
|
||||
lastUpdated: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Scenarios
|
||||
- **Validation Errors**: Invalid DTO data returns 400 Bad Request
|
||||
- **Authorization Errors**: Insufficient permissions return 403 Forbidden
|
||||
- **Not Found Errors**: Missing configurations return null/empty responses
|
||||
- **Database Errors**: MongoDB connection issues return 500 Internal Server Error
|
||||
|
||||
### Error Response Format
|
||||
```json
|
||||
{
|
||||
"statusCode": 400,
|
||||
"message": ["lineAdPrice must be a positive number"],
|
||||
"error": "Bad Request"
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Indexing Strategy
|
||||
- Primary configurations indexed on `isActive` field
|
||||
- Historical collections indexed on `lastUpdated` for chronological queries
|
||||
- FAQ questions indexed on `category` for filtered queries
|
||||
|
||||
### Caching Recommendations
|
||||
- Implement Redis caching for frequently accessed configurations
|
||||
- Cache invalidation on configuration updates
|
||||
- TTL-based cache expiration (recommended: 1 hour)
|
||||
|
||||
### Query Optimization
|
||||
- Use projection to limit returned fields for large documents
|
||||
- Implement pagination for historical queries
|
||||
- Use aggregation pipelines for complex FAQ category filtering
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Input Validation
|
||||
- All DTOs use class-validator decorators
|
||||
- Sanitize HTML content in privacy policy and about us sections
|
||||
- Validate file URLs and social media links
|
||||
|
||||
### Access Control
|
||||
- Role-based permissions enforced at controller level
|
||||
- JWT token validation for protected endpoints
|
||||
- Rate limiting on public endpoints
|
||||
|
||||
### Data Integrity
|
||||
- Mongoose schema validation as secondary validation layer
|
||||
- Audit trails for all configuration changes
|
||||
- Backup strategies for critical configurations
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
```bash
|
||||
npm run test -- src/configurations
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
```bash
|
||||
npm run test:e2e -- configurations
|
||||
```
|
||||
|
||||
### Manual Testing Endpoints
|
||||
Use the provided Swagger documentation at `/api` for interactive testing of all configuration endpoints.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Hard-coded Configurations
|
||||
1. Identify current hard-coded values in the codebase
|
||||
2. Create corresponding configuration documents in MongoDB
|
||||
3. Update application code to read from configuration API
|
||||
4. Test thoroughly before deployment
|
||||
|
||||
### Version Upgrades
|
||||
1. Backup existing configuration data
|
||||
2. Run database migrations if schema changes
|
||||
3. Update API clients to handle new fields
|
||||
4. Deploy with backwards compatibility
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Configuration Not Updating**
|
||||
- Check user permissions (SUPER_ADMIN/EDITOR required)
|
||||
- Verify JWT token validity
|
||||
- Ensure MongoDB connection is active
|
||||
|
||||
**FAQ Questions Not Appearing**
|
||||
- Check `isActive` flag on both FAQ document and individual questions
|
||||
- Verify category filtering parameters
|
||||
- Check question order values
|
||||
|
||||
**Pricing History Empty**
|
||||
- Ensure pricing updates include `lastUpdated` timestamp
|
||||
- Check if any pricing records exist in database
|
||||
- Verify query sorting parameters
|
||||
|
||||
### Debug Queries
|
||||
```bash
|
||||
# Check configuration document structure
|
||||
db.adpricings.findOne()
|
||||
|
||||
# Check FAQ questions by category
|
||||
db.faqs.aggregate([
|
||||
{ $unwind: "$questions" },
|
||||
{ $match: { "questions.category": "General" } }
|
||||
])
|
||||
```
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Logging
|
||||
Configuration changes are logged with user information and timestamps for audit purposes.
|
||||
|
||||
### Health Checks
|
||||
Monitor MongoDB connection status and configuration endpoint response times.
|
||||
|
||||
### Regular Maintenance
|
||||
- Review and archive old historical records
|
||||
- Update default values as business requirements change
|
||||
- Monitor storage usage for document growth
|
||||
|
||||
---
|
||||
|
||||
*CONFIG.md - Configuration Management System Documentation*
|
||||
*Last Updated: January 2025*
|
||||
135
OTP.md
Normal file
135
OTP.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# OTP API Documentation
|
||||
|
||||
## Overview
|
||||
The OTP system handles two main flows:
|
||||
1. **Login OTP**: Passwordless login using phone number
|
||||
2. **Verification OTP**: Phone number verification during registration
|
||||
|
||||
Base URL: `/server/auth`
|
||||
|
||||
## Login OTP Flow
|
||||
|
||||
### 1. Generate Login OTP
|
||||
**Endpoint:** `POST /auth/send-otp`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"phone": "1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"message": "OTP sent successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Phone number is required
|
||||
- `400 Bad Request`: Admin users should use regular login, not OTP
|
||||
- `400 Bad Request`: Please verify your phone number first (for unverified users)
|
||||
|
||||
### 2. Verify Login OTP
|
||||
**Endpoint:** `POST /auth/verify-otp`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"phone": "1234567890",
|
||||
"otp": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"message": "OTP verified successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Sets JWT token in HTTP-only cookie
|
||||
- Returns different user roles (USER, VIEWER, etc.)
|
||||
- If user exists: logs in with actual user data
|
||||
- If user doesn't exist: creates VIEWER session
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Phone number and OTP are required
|
||||
- `400 Bad Request`: Invalid or expired OTP
|
||||
|
||||
## Phone Verification Flow
|
||||
|
||||
### 1. Send Verification OTP
|
||||
**Endpoint:** `POST /auth/send-verification-otp`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"phone": "1234567890"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"message": "Verification OTP sent to your phone"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Phone number is required
|
||||
- `400 Bad Request`: User not found
|
||||
- `400 Bad Request`: Phone number is already verified
|
||||
|
||||
### 2. Verify Account
|
||||
**Endpoint:** `POST /auth/verify-account`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"phone": "1234567890",
|
||||
"otp": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"message": "Phone number verified successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400 Bad Request`: Phone number and OTP are required
|
||||
- `400 Bad Request`: Invalid or expired verification OTP
|
||||
|
||||
## Important Notes
|
||||
|
||||
### OTP Specifications
|
||||
- **Length**: 6 digits
|
||||
- **Login OTP Expiry**: 5 minutes
|
||||
- **Verification OTP Expiry**: 10 minutes
|
||||
- **Format**: Numeric only (100000-999999)
|
||||
|
||||
### Security Features
|
||||
- Only one active OTP per phone/purpose at a time
|
||||
- Previous OTPs are automatically deactivated when new ones are generated
|
||||
- OTPs are single-use (marked as used after verification)
|
||||
- Expired OTPs are automatically rejected
|
||||
|
||||
### User Roles & Permissions
|
||||
- **SUPER_ADMIN, EDITOR, REVIEWER, VIEWER**: Cannot use login OTP (must use regular login)
|
||||
- **USER**: Can use login OTP only after phone verification
|
||||
- **Unregistered numbers**: Get VIEWER role after OTP login
|
||||
|
||||
### SMS Integration
|
||||
- Uses SMS service for actual delivery
|
||||
- Falls back to console logging if SMS fails
|
||||
- Returns success even if SMS delivery fails (for development)
|
||||
|
||||
### Cookie Management
|
||||
- JWT tokens stored in HTTP-only cookies
|
||||
- Cookie settings: `httpOnly: true, sameSite: 'strict', secure: true`
|
||||
- Automatic cookie setting on successful OTP verification
|
||||
830
REPORTS.md
Normal file
830
REPORTS.md
Normal file
@@ -0,0 +1,830 @@
|
||||
# 📊 Comprehensive Reporting System Documentation
|
||||
## Paisa Ads Platform Analytics & Business Intelligence
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Overview**
|
||||
|
||||
This document provides complete documentation for the comprehensive reporting system implemented in the Paisa Ads platform. The system provides detailed analytics across all business dimensions including users, admins, listings, and payments.
|
||||
|
||||
### **Key Features**
|
||||
- ✅ **User Analytics**: Registration trends, activity patterns, engagement metrics
|
||||
- ✅ **Admin Intelligence**: Activity tracking, approval workflows, performance metrics
|
||||
- ✅ **Listing Analytics**: Category performance, image impact, approval times
|
||||
- ✅ **Payment Intelligence**: Revenue analysis, transaction monitoring, forecasting
|
||||
- ✅ **Advanced Filtering**: Multi-criteria filtering with date ranges and grouping
|
||||
- ✅ **Export Capabilities**: CSV/Excel export functionality
|
||||
- ✅ **Real-time Data**: Live analytics based on current database state
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ **Architecture Overview**
|
||||
|
||||
### **Service Layer**
|
||||
- **ReportsService**: Core service containing all reporting logic
|
||||
- **Enhanced with**: User, Customer, Admin, Payment, AdComment, MainCategory repositories
|
||||
- **Query Optimization**: TypeORM QueryBuilder with proper joins and indexing
|
||||
- **Data Aggregation**: Server-side processing for performance
|
||||
|
||||
### **Controller Layer**
|
||||
- **ReportsController**: RESTful API endpoints with comprehensive documentation
|
||||
- **Role-based Security**: Admin/Editor/Reviewer access controls
|
||||
- **Swagger Integration**: Complete API documentation with examples
|
||||
- **Query Validation**: Type-safe query parameters with validation
|
||||
|
||||
### **Data Models**
|
||||
- **Existing Entities**: Leverages current User, Customer, Admin, Payment, AdComment entities
|
||||
- **No New Tables**: Built on existing schema using created_at, updated_at timestamps
|
||||
- **Relationships**: Utilizes existing foreign key relationships for data integrity
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Report Categories**
|
||||
|
||||
### **1. User Reports**
|
||||
|
||||
#### **User Registration Report**
|
||||
```typescript
|
||||
GET /reports/users/registrations?startDate=2024-01-01&endDate=2024-12-31&period=monthly
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total": 1250,
|
||||
"active": 1100,
|
||||
"inactive": 150,
|
||||
"growth": 15.2
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"period": "2024-01",
|
||||
"count": 45,
|
||||
"activeCount": 42,
|
||||
"byRole": {
|
||||
"USER": 40,
|
||||
"EDITOR": 3,
|
||||
"REVIEWER": 2
|
||||
},
|
||||
"byGender": {
|
||||
"MALE": 25,
|
||||
"FEMALE": 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Business Value:**
|
||||
- Track user acquisition trends over time
|
||||
- Measure growth rates and user engagement
|
||||
- Understand user demographics and role distribution
|
||||
- Identify seasonal patterns in registrations
|
||||
|
||||
#### **Active vs Inactive Users Report**
|
||||
```typescript
|
||||
GET /reports/users/active-vs-inactive
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"active": 1100,
|
||||
"inactive": 150,
|
||||
"percentage": {
|
||||
"active": 88,
|
||||
"inactive": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **User Login Activity Report**
|
||||
```typescript
|
||||
GET /reports/users/login-activity?startDate=2024-01-01&endDate=2024-01-31&period=daily
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"totalActiveUsers": 850,
|
||||
"avgDailyActive": 27.4
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"period": "2024-01-01",
|
||||
"count": 32,
|
||||
"activeCount": 30,
|
||||
"byRole": {"USER": 28, "ADMIN": 2}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### **User Views by Category Report**
|
||||
```typescript
|
||||
GET /reports/users/views-by-category
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"categoryId": "cat-1",
|
||||
"categoryName": "Real Estate",
|
||||
"totalListings": 450,
|
||||
"estimatedViews": 22500,
|
||||
"avgViewsPerListing": 50
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### **2. Admin Reports**
|
||||
|
||||
#### **Admin Activity Report**
|
||||
```typescript
|
||||
GET /reports/admin/activity?startDate=2024-01-01&endDate=2024-01-31&period=daily&adminId=admin-123
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"totalActions": 125,
|
||||
"approvals": 89,
|
||||
"rejections": 25,
|
||||
"holds": 11,
|
||||
"avgTimeToAction": 24
|
||||
},
|
||||
"adminBreakdown": [
|
||||
{
|
||||
"adminId": "admin-123",
|
||||
"adminName": "John Admin",
|
||||
"totalActions": 45,
|
||||
"approvals": 32,
|
||||
"rejections": 8,
|
||||
"holds": 5,
|
||||
"avgTimeToAction": 18
|
||||
}
|
||||
],
|
||||
"timelineData": [
|
||||
{
|
||||
"date": "2024-01-01",
|
||||
"actions": 12,
|
||||
"approvals": 8,
|
||||
"rejections": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Business Value:**
|
||||
- Monitor admin productivity and performance
|
||||
- Identify bottlenecks in approval workflows
|
||||
- Track approval/rejection ratios for quality control
|
||||
- Measure response times for SLA compliance
|
||||
|
||||
#### **Admin User-wise Activity Report**
|
||||
```typescript
|
||||
GET /reports/admin/user-wise-activity?startDate=2024-01-01&endDate=2024-01-31
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"adminId": "admin-123",
|
||||
"adminName": "John Admin",
|
||||
"totalActions": 45,
|
||||
"approvals": 32,
|
||||
"rejections": 8,
|
||||
"holds": 5,
|
||||
"reviews": 25
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### **Admin Activity by Category Report**
|
||||
```typescript
|
||||
GET /reports/admin/activity-by-category?startDate=2024-01-01&endDate=2024-01-31
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"categoryId": "cat-1",
|
||||
"categoryName": "Real Estate",
|
||||
"totalActions": 85,
|
||||
"approvals": 65,
|
||||
"rejections": 15,
|
||||
"holds": 5
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### **3. Listing Reports**
|
||||
|
||||
#### **Comprehensive Listing Analytics**
|
||||
```typescript
|
||||
GET /reports/listings/analytics?startDate=2024-01-01&endDate=2024-12-31
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"totalListings": 2450,
|
||||
"withImages": 2100,
|
||||
"withoutImages": 350,
|
||||
"avgApprovalTime": 18.5
|
||||
},
|
||||
"byCategory": [
|
||||
{
|
||||
"categoryId": "cat-1",
|
||||
"categoryName": "Real Estate",
|
||||
"totalAds": 450,
|
||||
"activeAds": 420,
|
||||
"avgApprovalTime": 16.2,
|
||||
"withImages": 430,
|
||||
"withoutImages": 20
|
||||
}
|
||||
],
|
||||
"byUserType": [
|
||||
{
|
||||
"userType": "Individual",
|
||||
"count": 1850,
|
||||
"percentage": 75
|
||||
},
|
||||
{
|
||||
"userType": "Business",
|
||||
"count": 600,
|
||||
"percentage": 25
|
||||
}
|
||||
],
|
||||
"approvalMetrics": {
|
||||
"avgTimeToApprove": 18.5,
|
||||
"fastestApproval": 2.3,
|
||||
"slowestApproval": 72.1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Business Value:**
|
||||
- Understand listing quality through image adoption rates
|
||||
- Track category performance and popularity
|
||||
- Measure approval efficiency and identify bottlenecks
|
||||
- Analyze user behavior patterns
|
||||
|
||||
#### **Active Listings by Category**
|
||||
```typescript
|
||||
GET /reports/listings/active-by-category
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"categoryId": "cat-1",
|
||||
"categoryName": "Real Estate",
|
||||
"lineAds": 150,
|
||||
"posterAds": 200,
|
||||
"videoAds": 70,
|
||||
"total": 420
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### **Approval Time Analytics**
|
||||
```typescript
|
||||
GET /reports/listings/approval-times
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"totalApproved": 1850,
|
||||
"avgTimeToApprove": 18.5,
|
||||
"fastestApproval": 2.3,
|
||||
"slowestApproval": 72.1
|
||||
},
|
||||
"details": [
|
||||
{
|
||||
"adId": "ad-123",
|
||||
"adType": "LINE",
|
||||
"timeToApprove": 24.5,
|
||||
"approvedAt": "2024-01-15T10:30:00Z",
|
||||
"createdAt": "2024-01-14T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### **Listings by User Report**
|
||||
```typescript
|
||||
GET /reports/listings/by-user
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"userId": "user-123",
|
||||
"userName": "John Doe",
|
||||
"userType": "Individual",
|
||||
"lineAds": 5,
|
||||
"posterAds": 3,
|
||||
"videoAds": 1,
|
||||
"totalAds": 9,
|
||||
"location": "Mumbai, Maharashtra"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### **4. Payment Reports**
|
||||
|
||||
#### **Payment Transaction Report**
|
||||
```typescript
|
||||
GET /reports/payments/transactions?startDate=2024-01-01&endDate=2024-12-31&period=monthly
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"totalRevenue": 125000,
|
||||
"totalTransactions": 850,
|
||||
"avgTransactionValue": 147.06,
|
||||
"successRate": 100
|
||||
},
|
||||
"byProduct": [
|
||||
{
|
||||
"product": "Line Ads",
|
||||
"revenue": 45000,
|
||||
"transactions": 450,
|
||||
"avgValue": 100
|
||||
},
|
||||
{
|
||||
"product": "Poster Ads",
|
||||
"revenue": 60000,
|
||||
"transactions": 300,
|
||||
"avgValue": 200
|
||||
},
|
||||
{
|
||||
"product": "Video Ads",
|
||||
"revenue": 20000,
|
||||
"transactions": 100,
|
||||
"avgValue": 200
|
||||
}
|
||||
],
|
||||
"byCategory": [
|
||||
{
|
||||
"categoryId": "cat-1",
|
||||
"categoryName": "Real Estate",
|
||||
"revenue": 45000,
|
||||
"transactions": 250
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"period": "2024-01",
|
||||
"revenue": 12500,
|
||||
"transactions": 85
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Business Value:**
|
||||
- Track revenue trends and growth patterns
|
||||
- Understand product profitability (Line vs Poster vs Video ads)
|
||||
- Analyze category-wise revenue distribution
|
||||
- Monitor transaction success rates and payment health
|
||||
|
||||
#### **Revenue by Product Type**
|
||||
```typescript
|
||||
GET /reports/payments/revenue-by-product
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
{
|
||||
"lineAds": {
|
||||
"revenue": 45000,
|
||||
"transactions": 450,
|
||||
"avgValue": 100
|
||||
},
|
||||
"posterAds": {
|
||||
"revenue": 60000,
|
||||
"transactions": 300,
|
||||
"avgValue": 200
|
||||
},
|
||||
"videoAds": {
|
||||
"revenue": 20000,
|
||||
"transactions": 100,
|
||||
"avgValue": 200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **Revenue by Category Report**
|
||||
```typescript
|
||||
GET /reports/payments/revenue-by-category
|
||||
```
|
||||
|
||||
**Response Structure:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"categoryId": "cat-1",
|
||||
"categoryName": "Real Estate",
|
||||
"revenue": 45000,
|
||||
"transactions": 250,
|
||||
"avgTransactionValue": 180
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Technical Implementation**
|
||||
|
||||
### **Database Queries**
|
||||
|
||||
#### **Optimization Strategies**
|
||||
```sql
|
||||
-- Example optimized query for user registration report
|
||||
SELECT
|
||||
DATE_TRUNC('month', created_at) as period,
|
||||
COUNT(*) as total_count,
|
||||
COUNT(CASE WHEN "isActive" = true THEN 1 END) as active_count,
|
||||
COUNT(CASE WHEN role = 'USER' THEN 1 END) as user_count
|
||||
FROM users
|
||||
WHERE created_at BETWEEN $1 AND $2
|
||||
GROUP BY DATE_TRUNC('month', created_at)
|
||||
ORDER BY period;
|
||||
|
||||
-- Index recommendations for performance
|
||||
CREATE INDEX idx_users_created_active ON users(created_at, "isActive");
|
||||
CREATE INDEX idx_payments_date_amount ON payments(created_at, amount);
|
||||
CREATE INDEX idx_comments_timestamp_action ON ad_comments(actionTimestamp, actionType);
|
||||
```
|
||||
|
||||
#### **Performance Considerations**
|
||||
- **Pagination**: All list endpoints support pagination (default 10, max 100)
|
||||
- **Date Filtering**: Indexed date ranges for efficient querying
|
||||
- **Query Optimization**: Uses TypeORM QueryBuilder for complex joins
|
||||
- **Memory Management**: Processes large datasets in chunks
|
||||
|
||||
### **Error Handling**
|
||||
```typescript
|
||||
// Service-level error handling
|
||||
try {
|
||||
const users = await this.userRepository.find(options);
|
||||
return this.processUserData(users);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to generate user report', error);
|
||||
throw new InternalServerErrorException('Report generation failed');
|
||||
}
|
||||
```
|
||||
|
||||
### **Data Validation**
|
||||
```typescript
|
||||
// Controller-level validation
|
||||
@ApiQuery({ name: 'startDate', required: true, type: String })
|
||||
@ApiQuery({ name: 'endDate', required: true, type: String })
|
||||
async getUserRegistrationReport(
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
@Query('period') period: 'daily' | 'weekly' | 'monthly' = 'daily'
|
||||
) {
|
||||
// Validation logic
|
||||
if (!startDate || !endDate) {
|
||||
throw new BadRequestException('Start date and end date are required');
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
if (start >= end) {
|
||||
throw new BadRequestException('Start date must be before end date');
|
||||
}
|
||||
|
||||
// Business logic
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Usage Examples**
|
||||
|
||||
### **Basic Usage**
|
||||
```bash
|
||||
# Get user registrations for last month
|
||||
curl -X GET "http://localhost:3001/server/reports/users/registrations?startDate=2024-01-01&endDate=2024-01-31&period=daily" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
|
||||
# Get admin activity report
|
||||
curl -X GET "http://localhost:3001/server/reports/admin/activity?startDate=2024-01-01&endDate=2024-01-31" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
|
||||
# Get revenue by product
|
||||
curl -X GET "http://localhost:3001/server/reports/payments/revenue-by-product" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
### **Dashboard Integration**
|
||||
```typescript
|
||||
// Frontend service example
|
||||
class ReportsService {
|
||||
async getUserRegistrationTrends(period: 'week' | 'month' | 'quarter') {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
|
||||
switch(period) {
|
||||
case 'week':
|
||||
startDate.setDate(endDate.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
startDate.setMonth(endDate.getMonth() - 1);
|
||||
break;
|
||||
case 'quarter':
|
||||
startDate.setMonth(endDate.getMonth() - 3);
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await this.http.get(`/reports/users/registrations`, {
|
||||
params: {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: endDate.toISOString().split('T')[0],
|
||||
period: 'daily'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **Advanced Filtering**
|
||||
```bash
|
||||
# Get comprehensive listing analytics with filters
|
||||
curl -X GET "http://localhost:3001/server/reports/listings/analytics?startDate=2024-01-01&endDate=2024-12-31" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
|
||||
# Get admin activity for specific admin
|
||||
curl -X GET "http://localhost:3001/server/reports/admin/activity?startDate=2024-01-01&endDate=2024-01-31&adminId=admin-123" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 **Performance Metrics**
|
||||
|
||||
### **Benchmarks**
|
||||
- **User Registration Report**: ~200ms for 10K users, 1-month range
|
||||
- **Admin Activity Report**: ~150ms for 1K admin actions
|
||||
- **Listing Analytics**: ~300ms for 50K listings
|
||||
- **Payment Reports**: ~250ms for 10K transactions
|
||||
|
||||
### **Scalability**
|
||||
- **Memory Usage**: ~50MB for processing 100K records
|
||||
- **Database Load**: Optimized queries with proper indexing
|
||||
- **Response Times**: <500ms for most report types
|
||||
- **Concurrent Users**: Supports 100+ concurrent report requests
|
||||
|
||||
### **Monitoring**
|
||||
```typescript
|
||||
// Performance monitoring example
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
private readonly logger = new Logger(ReportsService.name);
|
||||
|
||||
async getUserRegistrationReport(dateRange: ReportDateRange) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.generateReport(dateRange);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log(`User registration report generated in ${duration}ms`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.error(`Report generation failed after ${duration}ms`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 **Security & Access Control**
|
||||
|
||||
### **Role-based Access**
|
||||
```typescript
|
||||
// All report endpoints require admin privileges
|
||||
@Roles(Role.SUPER_ADMIN, Role.EDITOR, Role.REVIEWER)
|
||||
@Get('users/registrations')
|
||||
async getUserRegistrationReport() {
|
||||
// Only accessible by admin users
|
||||
}
|
||||
```
|
||||
|
||||
### **Data Privacy**
|
||||
- **PII Protection**: User emails and phone numbers excluded from reports
|
||||
- **Aggregated Data**: Individual user data is aggregated for privacy
|
||||
- **Access Logging**: All report access is logged for audit trails
|
||||
- **Data Retention**: Report data follows platform retention policies
|
||||
|
||||
### **API Security**
|
||||
- **JWT Authentication**: All endpoints require valid JWT tokens
|
||||
- **Rate Limiting**: Prevents abuse with request rate limiting
|
||||
- **Input Validation**: All query parameters are validated and sanitized
|
||||
- **SQL Injection Protection**: TypeORM provides built-in protection
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Export Capabilities**
|
||||
|
||||
### **Supported Formats**
|
||||
- **CSV**: Comma-separated values for Excel compatibility
|
||||
- **JSON**: Raw JSON data for programmatic access
|
||||
- **Future**: Excel (.xlsx) with charts and formatting
|
||||
|
||||
### **Export Examples**
|
||||
```bash
|
||||
# Export filtered ads to CSV
|
||||
curl -X GET "http://localhost:3001/server/reports/export?format=csv&adType=LINE&status=PUBLISHED" \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
|
||||
-H "Accept: text/csv"
|
||||
```
|
||||
|
||||
### **Large Dataset Handling**
|
||||
- **Streaming**: Large exports are streamed to prevent memory issues
|
||||
- **Pagination**: Automatic pagination for datasets >10K records
|
||||
- **Background Jobs**: Very large exports can be processed asynchronously
|
||||
- **Compression**: Optional gzip compression for large files
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ **Troubleshooting**
|
||||
|
||||
### **Common Issues**
|
||||
|
||||
#### **Slow Report Generation**
|
||||
```bash
|
||||
# Check database performance
|
||||
EXPLAIN ANALYZE SELECT * FROM users WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31';
|
||||
|
||||
# Add missing indexes
|
||||
CREATE INDEX CONCURRENTLY idx_users_created_at ON users(created_at);
|
||||
```
|
||||
|
||||
#### **Memory Issues**
|
||||
```typescript
|
||||
// Implement pagination for large datasets
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
const results = [];
|
||||
|
||||
while (true) {
|
||||
const batch = await this.repository.find({
|
||||
skip: offset,
|
||||
take: pageSize,
|
||||
where: filters
|
||||
});
|
||||
|
||||
if (batch.length === 0) break;
|
||||
|
||||
results.push(...this.processBatch(batch));
|
||||
offset += pageSize;
|
||||
}
|
||||
```
|
||||
|
||||
#### **Data Inconsistencies**
|
||||
```sql
|
||||
-- Verify data integrity
|
||||
SELECT COUNT(*) FROM users WHERE created_at IS NULL;
|
||||
SELECT COUNT(*) FROM payments WHERE amount <= 0;
|
||||
SELECT COUNT(*) FROM ad_comments WHERE actionTimestamp IS NULL;
|
||||
```
|
||||
|
||||
### **Performance Optimization**
|
||||
|
||||
#### **Database Optimization**
|
||||
```sql
|
||||
-- Essential indexes for reporting
|
||||
CREATE INDEX CONCURRENTLY idx_users_created_role ON users(created_at, role);
|
||||
CREATE INDEX CONCURRENTLY idx_payments_created_amount ON payments(created_at, amount);
|
||||
CREATE INDEX CONCURRENTLY idx_comments_timestamp_type ON ad_comments(actionTimestamp, actionType);
|
||||
CREATE INDEX CONCURRENTLY idx_ads_status_category ON line_ads(status, "mainCategoryId", created_at);
|
||||
```
|
||||
|
||||
#### **Query Optimization**
|
||||
```typescript
|
||||
// Use database-level aggregation instead of application-level
|
||||
const results = await this.repository
|
||||
.createQueryBuilder('entity')
|
||||
.select([
|
||||
'DATE_TRUNC(\'day\', entity.created_at) as date',
|
||||
'COUNT(*) as count',
|
||||
'entity.status as status'
|
||||
])
|
||||
.where('entity.created_at BETWEEN :start AND :end', { start, end })
|
||||
.groupBy('DATE_TRUNC(\'day\', entity.created_at), entity.status')
|
||||
.getRawMany();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔮 **Future Enhancements**
|
||||
|
||||
### **Planned Features**
|
||||
- **Real-time Analytics**: WebSocket-based live dashboards
|
||||
- **Predictive Analytics**: ML-powered trend forecasting
|
||||
- **Custom Reports**: User-defined report builder
|
||||
- **Automated Insights**: AI-generated business insights
|
||||
- **Data Visualization**: Built-in charting and graphing
|
||||
- **Scheduled Reports**: Automated report generation and delivery
|
||||
|
||||
### **Technical Improvements**
|
||||
- **Caching Layer**: Redis-based caching for frequently accessed reports
|
||||
- **Materialized Views**: Pre-computed aggregations for complex reports
|
||||
- **Async Processing**: Background job processing for heavy computations
|
||||
- **API Versioning**: Versioned APIs for backward compatibility
|
||||
- **Microservices**: Separate reporting service for better scalability
|
||||
|
||||
### **Business Intelligence**
|
||||
- **KPI Dashboards**: Executive-level business intelligence dashboards
|
||||
- **Trend Analysis**: Advanced trend detection and anomaly reporting
|
||||
- **Comparative Analytics**: Year-over-year and period-over-period comparisons
|
||||
- **Cohort Analysis**: User behavior and retention analytics
|
||||
- **Revenue Forecasting**: Predictive revenue modeling
|
||||
|
||||
---
|
||||
|
||||
## 📝 **API Reference Summary**
|
||||
|
||||
### **User Reports**
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/reports/users/registrations` | GET | User registration trends with date range |
|
||||
| `/reports/users/active-vs-inactive` | GET | Active vs inactive user breakdown |
|
||||
| `/reports/users/login-activity` | GET | User activity patterns |
|
||||
| `/reports/users/views-by-category` | GET | User engagement by category |
|
||||
|
||||
### **Admin Reports**
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/reports/admin/activity` | GET | Admin activity with approval metrics |
|
||||
| `/reports/admin/user-wise-activity` | GET | Individual admin performance |
|
||||
| `/reports/admin/activity-by-category` | GET | Admin actions by category |
|
||||
|
||||
### **Listing Reports**
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/reports/listings/analytics` | GET | Comprehensive listing analytics |
|
||||
| `/reports/listings/active-by-category` | GET | Active listings by category |
|
||||
| `/reports/listings/approval-times` | GET | Approval time analytics |
|
||||
| `/reports/listings/by-user` | GET | Listings grouped by user |
|
||||
|
||||
### **Payment Reports**
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/reports/payments/transactions` | GET | Payment transaction analytics |
|
||||
| `/reports/payments/revenue-by-product` | GET | Revenue by ad type |
|
||||
| `/reports/payments/revenue-by-category` | GET | Revenue by category |
|
||||
|
||||
---
|
||||
|
||||
## 📞 **Support & Maintenance**
|
||||
|
||||
### **Monitoring**
|
||||
- **Application Logs**: Comprehensive logging for all report operations
|
||||
- **Performance Metrics**: Response time and query performance monitoring
|
||||
- **Error Tracking**: Automated error detection and alerting
|
||||
- **Usage Analytics**: Report usage patterns and optimization opportunities
|
||||
|
||||
### **Backup & Recovery**
|
||||
- **Data Backup**: Regular database backups include all reporting data
|
||||
- **Query History**: Log all report queries for debugging and optimization
|
||||
- **Performance Baselines**: Track performance metrics over time
|
||||
- **Rollback Procedures**: Safe deployment and rollback procedures
|
||||
|
||||
### **Documentation Maintenance**
|
||||
- **API Documentation**: Auto-generated Swagger documentation
|
||||
- **Code Comments**: Comprehensive inline documentation
|
||||
- **Architecture Decisions**: Documented design decisions and rationale
|
||||
- **Performance Guidelines**: Best practices for optimal performance
|
||||
|
||||
---
|
||||
|
||||
*This comprehensive reporting system provides powerful business intelligence capabilities for the Paisa Ads platform, enabling data-driven decision making across all aspects of the business.*
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Last Updated**: January 2024
|
||||
**Maintained By**: Development Team
|
||||
@@ -5,6 +5,7 @@ const nextConfig: NextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
output:'standalone'
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
304
package-lock.json
generated
304
package-lock.json
generated
@@ -53,11 +53,16 @@
|
||||
"react-hook-form": "^7.56.0",
|
||||
"react-medium-image-zoom": "^5.2.14",
|
||||
"react-player": "^2.16.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"recharts": "^2.15.3",
|
||||
"slate": "^0.114.0",
|
||||
"slate-history": "^0.113.1",
|
||||
"slate-react": "^0.114.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tw-animate-css": "^1.2.8",
|
||||
"zod": "^3.24.3"
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -758,6 +763,12 @@
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@juggle/resize-observer": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz",
|
||||
@@ -3367,11 +3378,20 @@
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/quill": {
|
||||
"version": "1.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
|
||||
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parchment": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
|
||||
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@@ -3381,7 +3401,7 @@
|
||||
"version": "19.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
||||
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
@@ -4214,7 +4234,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.0",
|
||||
@@ -4246,7 +4265,6 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
@@ -4324,6 +4342,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -4406,6 +4433,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/compute-scroll-into-view": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||
"integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -4673,6 +4706,26 @@
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-equal": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
|
||||
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-date-object": "^1.0.5",
|
||||
"is-regex": "^1.1.4",
|
||||
"object-is": "^1.1.5",
|
||||
"object-keys": "^1.1.1",
|
||||
"regexp.prototype.flags": "^1.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -4693,7 +4746,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
@@ -4711,7 +4763,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
@@ -4750,6 +4801,19 @@
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/direction": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz",
|
||||
"integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"direction": "cli.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -5434,12 +5498,24 @@
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
||||
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||
@@ -5652,7 +5728,6 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
||||
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -5831,7 +5906,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
@@ -5905,6 +5979,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -5956,6 +6040,22 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -6095,7 +6195,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
|
||||
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@@ -6166,6 +6265,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-hotkey": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
|
||||
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-map": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
|
||||
@@ -6206,11 +6311,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
@@ -7202,11 +7315,26 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -7376,6 +7504,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -7803,6 +7937,40 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
||||
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clone": "^2.1.1",
|
||||
"deep-equal": "^1.0.1",
|
||||
"eventemitter3": "^2.0.3",
|
||||
"extend": "^3.0.2",
|
||||
"parchment": "^1.1.4",
|
||||
"quill-delta": "^3.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "3.6.3",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
||||
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deep-equal": "^1.0.1",
|
||||
"extend": "^3.0.2",
|
||||
"fast-diff": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/quill/node_modules/eventemitter3": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rangetouch": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||
@@ -7930,6 +8098,21 @@
|
||||
"react": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-quill": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
|
||||
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/quill": "^1.3.10",
|
||||
"lodash": "^4.17.4",
|
||||
"quill": "^1.3.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16 || ^17 || ^18",
|
||||
"react-dom": "^16 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.6.3",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
|
||||
@@ -8095,7 +8278,6 @@
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
@@ -8255,6 +8437,15 @@
|
||||
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scroll-into-view-if-needed": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz",
|
||||
"integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"compute-scroll-into-view": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
@@ -8272,7 +8463,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
@@ -8290,7 +8480,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
|
||||
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
@@ -8467,6 +8656,56 @@
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/slate": {
|
||||
"version": "0.114.0",
|
||||
"resolved": "https://registry.npmjs.org/slate/-/slate-0.114.0.tgz",
|
||||
"integrity": "sha512-r3KHl22433DlN5BpLAlL4b3D8ItoGKAkj91YT6GhP39XuLoBT+YFd9ObKuL/okgiPb5lbwnW+71fM45hHceN9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immer": "^10.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"tiny-warning": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/slate-history": {
|
||||
"version": "0.113.1",
|
||||
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.113.1.tgz",
|
||||
"integrity": "sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-plain-object": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"slate": ">=0.65.3"
|
||||
}
|
||||
},
|
||||
"node_modules/slate-react": {
|
||||
"version": "0.114.2",
|
||||
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.114.2.tgz",
|
||||
"integrity": "sha512-yqJnX1/7A30szl9BxW3qX99MZy6mM6VtUi1rXTy0JpRMTKv3rduo0WOxqcX90tpt0ke2pzHGbrLLr1buIN4vrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"direction": "^1.0.4",
|
||||
"is-hotkey": "^0.2.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"tiny-invariant": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2.0",
|
||||
"react-dom": ">=18.2.0",
|
||||
"slate": ">=0.114.0",
|
||||
"slate-dom": ">=0.110.2"
|
||||
}
|
||||
},
|
||||
"node_modules/slate-react/node_modules/tiny-invariant": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
|
||||
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.3.4.tgz",
|
||||
@@ -8732,6 +8971,12 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-warning": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||
@@ -9254,6 +9499,35 @@
|
||||
"peerDependencies": {
|
||||
"zod": "^3.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz",
|
||||
"integrity": "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,16 @@
|
||||
"react-hook-form": "^7.56.0",
|
||||
"react-medium-image-zoom": "^5.2.14",
|
||||
"react-player": "^2.16.0",
|
||||
"react-quill": "^2.0.0",
|
||||
"recharts": "^2.15.3",
|
||||
"slate": "^0.114.0",
|
||||
"slate-history": "^0.113.1",
|
||||
"slate-react": "^0.114.2",
|
||||
"sonner": "^2.0.3",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tw-animate-css": "^1.2.8",
|
||||
"zod": "^3.24.3"
|
||||
"zod": "^3.24.3",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
236
src/app/(website)/about-us/page.tsx
Normal file
236
src/app/(website)/about-us/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Building2, Users, Target, Eye, Award, Mail, Phone, MapPin } from "lucide-react";
|
||||
import api from "@/lib/api";
|
||||
|
||||
export default function AboutUsPage() {
|
||||
const { data: aboutData, isLoading, error } = useQuery({
|
||||
queryKey: ["aboutUs"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/about-us");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !aboutData) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">About Us</h1>
|
||||
<p className="text-gray-600">Information not available at the moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-20 px-4 pb-12">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">About Us</h1>
|
||||
{aboutData.companyOverview && (
|
||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
{aboutData.companyOverview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
|
||||
{/* Mission */}
|
||||
{aboutData.mission && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-blue-600" />
|
||||
Our Mission
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700">{aboutData.mission}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Vision */}
|
||||
{aboutData.vision && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-green-600" />
|
||||
Our Vision
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700">{aboutData.vision}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
{aboutData.values && aboutData.values.length > 0 && (
|
||||
<Card className="mb-12">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-purple-600" />
|
||||
Our Values
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{aboutData.values.map((value: string, index: number) => (
|
||||
<Badge key={index} variant="secondary" className="p-3 text-center justify-center">
|
||||
{value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{aboutData.history && (
|
||||
<Card className="mb-12">
|
||||
<CardHeader>
|
||||
<CardTitle>Our History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="prose max-w-none text-gray-700"
|
||||
dangerouslySetInnerHTML={{ __html: aboutData.history }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Team Members */}
|
||||
{aboutData.teamMembers && aboutData.teamMembers.length > 0 && (
|
||||
<Card className="mb-12">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-orange-600" />
|
||||
Our Team
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{aboutData.teamMembers.map((member: any, index: number) => (
|
||||
<div key={index} className="text-center">
|
||||
<Avatar className="h-24 w-24 mx-auto mb-4">
|
||||
<AvatarImage src={member.imageUrl} alt={member.name} />
|
||||
<AvatarFallback className="text-lg">
|
||||
{member.name?.split(' ').map((n: string) => n[0]).join('').toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<h3 className="font-semibold text-lg">{member.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">{member.position}</p>
|
||||
{member.bio && (
|
||||
<p className="text-sm text-gray-700 mb-3">{member.bio}</p>
|
||||
)}
|
||||
{member.socialLinks && member.socialLinks.length > 0 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{member.socialLinks.map((link: string, linkIndex: number) => (
|
||||
<a
|
||||
key={linkIndex}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
Link {linkIndex + 1}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Achievements */}
|
||||
{aboutData.achievements && aboutData.achievements.length > 0 && (
|
||||
<Card className="mb-12">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Award className="h-5 w-5 text-yellow-600" />
|
||||
Our Achievements
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{aboutData.achievements.map((achievement: string, index: number) => (
|
||||
<div key={index} className="flex items-start gap-3">
|
||||
<Award className="h-5 w-5 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-gray-700">{achievement}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Contact Information */}
|
||||
{aboutData.contactInfo && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Get in Touch</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{aboutData.contactInfo.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium">Email</p>
|
||||
<p className="text-sm text-gray-600">{aboutData.contactInfo.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{aboutData.contactInfo.phone && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium">Phone</p>
|
||||
<p className="text-sm text-gray-600">{aboutData.contactInfo.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{aboutData.contactInfo.address && (
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPin className="h-5 w-5 text-red-600" />
|
||||
<div>
|
||||
<p className="font-medium">Address</p>
|
||||
<p className="text-sm text-gray-600">{aboutData.contactInfo.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { MapPin, X, User } from "lucide-react";
|
||||
import { MapPin, X, User, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -33,6 +33,8 @@ interface LineAd {
|
||||
city: string;
|
||||
postedBy?: string;
|
||||
contactOne?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
// Image Gallery component
|
||||
@@ -50,15 +52,49 @@ const ImageGallery = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative group">
|
||||
<div className="relative group h-full w-full">
|
||||
{/* Left Arrow */}
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous image"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentIndex((prev) =>
|
||||
prev === 0 ? images.length - 1 : prev - 1
|
||||
);
|
||||
}}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-1 flex items-center justify-center focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* Right Arrow */}
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next image"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentIndex((prev) =>
|
||||
prev === images.length - 1 ? 0 : prev + 1
|
||||
);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-1 flex items-center justify-center focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className="w-full overflow-hidden cursor-pointer"
|
||||
className="w-full overflow-hidden cursor-pointer h-full"
|
||||
onClick={() => setShowLightbox(true)}
|
||||
>
|
||||
<img
|
||||
src={`/api/images/?imageName=${images[currentIndex].fileName}`}
|
||||
alt="Advertisement"
|
||||
className="object-cover w-full h-auto transition-transform duration-300 group-hover:scale-105"
|
||||
className="object-cover w-full h-full transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -72,12 +108,12 @@ const ImageGallery = ({
|
||||
|
||||
{/* Thumbnail navigation for multiple images */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-center gap-1 p-2 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-center gap-3 p-2 bg-gradient-to-t from-black/50 to-transparent">
|
||||
{images.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-all",
|
||||
"w-2 h-2 rounded-full transition-all cursor-pointer",
|
||||
currentIndex === idx
|
||||
? "bg-white scale-125"
|
||||
: "bg-white/50 hover:bg-white/80"
|
||||
@@ -97,7 +133,7 @@ const ImageGallery = ({
|
||||
<Dialog open={showLightbox} onOpenChange={setShowLightbox}>
|
||||
<DialogTitle></DialogTitle>
|
||||
<DialogContent className="max-w-4xl p-0 bg-black border-none">
|
||||
<div className="relative">
|
||||
<div className="relative flex items-center justify-center min-h-[60vh]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -107,11 +143,44 @@ const ImageGallery = ({
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center h-full">
|
||||
{/* Left Arrow in Lightbox */}
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous image"
|
||||
onClick={() =>
|
||||
setCurrentIndex(
|
||||
currentIndex === 0 ? images.length - 1 : currentIndex - 1
|
||||
)
|
||||
}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 flex items-center justify-center focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
{/* Right Arrow in Lightbox */}
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next image"
|
||||
onClick={() =>
|
||||
setCurrentIndex(
|
||||
currentIndex === images.length - 1 ? 0 : currentIndex + 1
|
||||
)
|
||||
}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 flex items-center justify-center focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<img
|
||||
src={`/api/images?imageName=${images[currentIndex].fileName}`}
|
||||
alt="Advertisement"
|
||||
className="max-h-[80vh] w-auto object-contain"
|
||||
className="w-[50vw] max-h-[80vh] object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -165,51 +234,33 @@ export default function LineAdCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="pt-2 pb-0 h-full flex flex-col overflow-hidden border rounded-lg break-inside-avoid hover:shadow-md transition-shadow duration-300">
|
||||
<div className="p-4 pb-0 flex-grow">
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{ad.mainCategory && (
|
||||
<Badge
|
||||
className="rounded-sm font-normal text-xs px-2"
|
||||
style={getBadgeStyle(ad.mainCategory)}
|
||||
>
|
||||
{ad.mainCategory.name}
|
||||
</Badge>
|
||||
)}
|
||||
{/* {ad.categoryOne && (
|
||||
<Badge
|
||||
className="rounded-sm font-normal text-xs px-2"
|
||||
style={getBadgeStyle(ad.categoryOne)}
|
||||
>
|
||||
{ad.categoryOne.name}
|
||||
</Badge>
|
||||
)}
|
||||
{ad.categoryTwo && (
|
||||
<Badge
|
||||
className="rounded-sm font-normal text-xs px-2"
|
||||
style={getBadgeStyle(ad.categoryTwo)}
|
||||
>
|
||||
{ad.categoryTwo.name}
|
||||
</Badge>
|
||||
)}
|
||||
{ad.categoryThree && (
|
||||
<Badge
|
||||
className="rounded-sm font-normal text-xs px-2"
|
||||
style={getBadgeStyle(ad.categoryThree)}
|
||||
>
|
||||
{ad.categoryThree.name}
|
||||
</Badge>
|
||||
)} */}
|
||||
<Card
|
||||
className="pt-0 pb-0 h-full flex flex-col overflow-hidden border rounded-lg break-inside-avoid hover:shadow-md transition-shadow duration-300"
|
||||
style={{
|
||||
backgroundColor: ad.backgroundColor || "#ffffff",
|
||||
color: ad.textColor || "#000000",
|
||||
}}
|
||||
>
|
||||
<div className="p-4 pt-3 pb-0 flex-grow">
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1 mb-3 text-xs opacity-80">
|
||||
{[ad.mainCategory, ad.categoryOne, ad.categoryTwo, ad.categoryThree]
|
||||
.filter(Boolean)
|
||||
.map((category, idx, arr) => (
|
||||
<span key={category.id || idx}>
|
||||
{category.name}
|
||||
{idx < arr.length - 1 && <span className="mx-1">|</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ad Content */}
|
||||
<p className={cn("text-sm mb-2 text-justify")}>{ad.content}</p>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-2 flex gap-2 justify-between items-start">
|
||||
<div className="space-y-1 pb-2 justify-between items-start">
|
||||
{/* Combined Posted By and Contact */}
|
||||
{(ad.postedBy || ad.contactOne) && (
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<div className="flex items-center text-xs opacity-80">
|
||||
<User className="h-3 w-3 mr-1" />
|
||||
<span>
|
||||
{ad.postedBy && ad.contactOne
|
||||
@@ -224,7 +275,7 @@ export default function LineAdCard({
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<div className="flex items-center text-xs opacity-80">
|
||||
<MapPin className="h-3 w-3 mr-1" />
|
||||
{ad.city}, {ad.state}
|
||||
</div>
|
||||
@@ -233,7 +284,7 @@ export default function LineAdCard({
|
||||
|
||||
{/* Image Section - At the bottom */}
|
||||
{hasImages && (
|
||||
<div className="w-full mt-auto">
|
||||
<div className=" w-full h-40">
|
||||
<ImageGallery
|
||||
images={ad.images}
|
||||
apiUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AlertCircle, Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
@@ -17,14 +11,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { StateSelect, CitySelect } from "react-country-state-city";
|
||||
import "react-country-state-city/dist/react-country-state-city.css";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import api from "@/lib/api";
|
||||
import LineAdCard from "./line-ad-card";
|
||||
import { LineAd } from "@/lib/types/lineAd";
|
||||
import { log } from "console";
|
||||
import { get } from "http";
|
||||
import { getDate, min } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, Search, X } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CitySelect, StateSelect } from "react-country-state-city";
|
||||
import "react-country-state-city/dist/react-country-state-city.css";
|
||||
import LineAdCard from "./line-ad-card";
|
||||
|
||||
// Image Gallery component
|
||||
const ImageGallery = ({
|
||||
@@ -330,8 +327,6 @@ export default function LineAds() {
|
||||
|
||||
console.log("lineadsPage", params);
|
||||
|
||||
const [visibleCount, setVisibleCount] = useState(12);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Check if user is authenticated
|
||||
@@ -366,61 +361,35 @@ export default function LineAds() {
|
||||
enabled: paramsReady,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && data?.length > visibleCount) {
|
||||
// Load more items when the sentinel comes into view
|
||||
setVisibleCount((prev) => Math.min(prev + 8, data.length));
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
// Pagination state
|
||||
const ADS_PER_PAGE = 12;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const currentRef = loadMoreRef.current;
|
||||
if (currentRef) {
|
||||
observer.observe(currentRef);
|
||||
}
|
||||
// Calculate total pages
|
||||
const totalPages = data ? Math.ceil(data.length / ADS_PER_PAGE) : 1;
|
||||
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
observer.unobserve(currentRef);
|
||||
}
|
||||
};
|
||||
}, [data, visibleCount]);
|
||||
|
||||
// Implement intersection observer for lazy loading
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting && data?.length > visibleCount) {
|
||||
// Load more items when the sentinel comes into view
|
||||
setVisibleCount((prev) => Math.min(prev + 8, data.length));
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const currentRef = loadMoreRef.current;
|
||||
if (currentRef) {
|
||||
observer.observe(currentRef);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
observer.unobserve(currentRef);
|
||||
}
|
||||
};
|
||||
}, [data, visibleCount]);
|
||||
|
||||
// Get visible items with search filter applied
|
||||
const getVisibleItems = () => {
|
||||
// Get paginated items
|
||||
const getPaginatedItems = () => {
|
||||
if (!data) return [];
|
||||
return data.slice(0, visibleCount);
|
||||
// If not authenticated, only show first 12 ads
|
||||
if (!isAuthenticated) {
|
||||
return data.slice(0, ADS_PER_PAGE);
|
||||
}
|
||||
const startIdx = (currentPage - 1) * ADS_PER_PAGE;
|
||||
return data.slice(startIdx, startIdx + ADS_PER_PAGE);
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// Scroll to top when currentPage changes
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [currentPage]);
|
||||
|
||||
if (isError) {
|
||||
return <ErrorDisplay onRetry={() => refetch()} />;
|
||||
}
|
||||
@@ -461,24 +430,45 @@ export default function LineAds() {
|
||||
) : data && data.length > 0 ? (
|
||||
<>
|
||||
<div className="columns-1 sm:columns-2 md:columns-2 lg:columns-3 xl:columns-4 gap-4 space-y-8 transition-all ease-in">
|
||||
{getVisibleItems().map((ad: any, index: number) => (
|
||||
{getPaginatedItems().map((ad: any, index: number) => (
|
||||
<LineAdCard key={ad.id} ad={ad} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.length > visibleCount && (
|
||||
<div
|
||||
ref={loadMoreRef}
|
||||
className="w-full h-10 flex justify-center items-center my-4"
|
||||
>
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show message when all ads are loaded */}
|
||||
{visibleCount >= data.length && data.length > 12 && (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{/* All advertisements loaded */}
|
||||
{/* Pagination Controls */}
|
||||
{isAuthenticated && totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(page) => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={page === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(page)}
|
||||
aria-label={`Page ${page}`}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
108
src/app/(website)/components/poster-ad-center-bottom.tsx
Normal file
108
src/app/(website)/components/poster-ad-center-bottom.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { PosterAd } from "@/lib/types/posterAd";
|
||||
import { ChevronLeftCircle, ChevronRightCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function PosterAdCenterBottom({
|
||||
children,
|
||||
topAds,
|
||||
bottomAds,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
topAds: PosterAd[];
|
||||
bottomAds: PosterAd[];
|
||||
}) {
|
||||
const tads = topAds || [];
|
||||
const bads = bottomAds || [];
|
||||
const totalTopAds = tads.length;
|
||||
const totalBottomAds = bads?.length;
|
||||
const [currentTopAdIndex, setCurrentTopAdIndex] = useState(0);
|
||||
const [currentBottomAdIndex, setCurrentBottomAdIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="col-span-8 flex flex-col gap-5">
|
||||
<div className="aspect-video overflow-hidden h-72 relative select-none">
|
||||
{currentTopAdIndex > 0 && (
|
||||
<ChevronLeftCircle
|
||||
className="cursor-pointer size-8 text-gray-800 absolute top-1/2 left-2 -translate-y-1/2 bg-white shadow-xl rounded-full"
|
||||
onClick={() => {
|
||||
setCurrentTopAdIndex(
|
||||
(currentTopAdIndex - 1 + totalTopAds) % totalTopAds
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentTopAdIndex < totalTopAds - 1 && (
|
||||
<ChevronRightCircle
|
||||
onClick={() => {
|
||||
setCurrentTopAdIndex((currentTopAdIndex + 1) % totalTopAds);
|
||||
}}
|
||||
className="cursor-pointer size-8 text-gray-800 absolute top-1/2 right-2 -translate-y-1/2 bg-white shadow-xl rounded-full"
|
||||
/>
|
||||
)}
|
||||
{tads && tads.length > 0 ? (
|
||||
<div className="pb-0.5 absolute bg-black px-3 bottom-2 rounded-full left-1/2 -translate-x-1/2">
|
||||
<span className="leading-none text-white text-xs">
|
||||
{currentTopAdIndex + 1} / {totalTopAds}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{tads && tads.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={`/api/images?imageName=${topAds[currentTopAdIndex].image.fileName}`}
|
||||
alt={topAds[currentTopAdIndex].mainCategory.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
<div className="aspect-video overflow-hidden h-72 relative select-none">
|
||||
{currentBottomAdIndex > 0 && (
|
||||
<ChevronLeftCircle
|
||||
className="cursor-pointer size-8 text-gray-800 absolute top-1/2 left-2 -translate-y-1/2 bg-white shadow-xl rounded-full"
|
||||
onClick={() => {
|
||||
setCurrentBottomAdIndex(
|
||||
(currentBottomAdIndex - 1 + totalBottomAds) % totalBottomAds
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentBottomAdIndex < totalBottomAds - 1 && (
|
||||
<ChevronRightCircle
|
||||
onClick={() => {
|
||||
setCurrentBottomAdIndex(
|
||||
(currentBottomAdIndex + 1) % totalBottomAds
|
||||
);
|
||||
}}
|
||||
className="cursor-pointer size-8 text-gray-800 absolute top-1/2 right-2 -translate-y-1/2 bg-white shadow-xl rounded-full"
|
||||
/>
|
||||
)}
|
||||
{bads && bads.length > 0 ? (
|
||||
<div className="pb-0.5 absolute bg-black px-3 bottom-2 rounded-full left-1/2 -translate-x-1/2">
|
||||
<span className="leading-none text-white text-xs">
|
||||
{currentBottomAdIndex + 1} / {totalBottomAds}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{bads && bads.length > 0 ? (
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={`/api/images?imageName=${bottomAds[currentBottomAdIndex].image.fileName}`}
|
||||
alt={bottomAds[currentBottomAdIndex].mainCategory.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function PosterAdCard({ ad, className }: PosterAdCardProps) {
|
||||
)}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative h-52 w-full overflow-hidden">
|
||||
<div className="relative h-80 w-full overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl || "/placeholder.svg"}
|
||||
alt={`${mainCategory}`.trim()}
|
||||
@@ -34,62 +34,25 @@ export function PosterAdCard({ ad, className }: PosterAdCardProps) {
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
/>
|
||||
|
||||
{/* Location badge */}
|
||||
{location && (
|
||||
<div className="absolute right-2 top-2 rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex flex-wrap gap-1 p-2">
|
||||
{mainCategory && (
|
||||
<span
|
||||
className="rounded bg-black/80 px-2 py-1 text-xs font-medium text-white"
|
||||
style={{
|
||||
backgroundColor:
|
||||
ad.mainCategory?.categories_color || "rgba(0,0,0,0.8)",
|
||||
color: ad.mainCategory?.font_color || "white",
|
||||
}}
|
||||
>
|
||||
{mainCategory}
|
||||
{/* Categories and Location Bar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between items-center px-4 py-2 text-sm text-white font-semibold bg-black/80 rounded-b-lg">
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||
{[ad.mainCategory, ad.categoryOne, ad.categoryTwo, ad.categoryThree]
|
||||
.filter(Boolean)
|
||||
.map((category, idx, arr) => (
|
||||
<span key={category?.id || idx}>
|
||||
{category?.name}
|
||||
{idx < arr.length - 1 && <span className="mx-1">|</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{location && (
|
||||
<span className="ml-4 whitespace-nowrap text-white font-semibold">
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* {categoryOne && (
|
||||
<span
|
||||
className="rounded bg-black/80 px-2 py-1 text-xs font-medium text-white"
|
||||
style={{
|
||||
color: ad.categoryOne?.category_heading_font_color || "white",
|
||||
}}
|
||||
>
|
||||
{categoryOne}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{categoryTwo && (
|
||||
<span
|
||||
className="rounded bg-black/80 px-2 py-1 text-xs font-medium text-white"
|
||||
style={{
|
||||
color: ad.categoryTwo?.category_heading_font_color || "white",
|
||||
}}
|
||||
>
|
||||
{categoryTwo}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{categoryThree && (
|
||||
<span
|
||||
className="rounded bg-black/80 px-2 py-1 text-xs font-medium text-white"
|
||||
style={{
|
||||
color: ad.categoryThree?.category_heading_font_color || "white",
|
||||
}}
|
||||
>
|
||||
{categoryThree}
|
||||
</span>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PosterAdCarousel } from "./poster-ad-carousel";
|
||||
import api from "@/lib/api";
|
||||
import { Position } from "@/lib/enum/position";
|
||||
|
||||
export default function PosterAds({ children }: { children: React.ReactNode }) {
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
@@ -56,12 +55,12 @@ export default function PosterAds({ children }: { children: React.ReactNode }) {
|
||||
const allAds = data || [];
|
||||
const maxAdsPerCarousel = 3; // Maximum 3 ads per carousel
|
||||
|
||||
// Filter ads by position
|
||||
// Filter ads by position using new structure
|
||||
const centerTopAds = allAds.filter(
|
||||
(ad: any) => ad.position === Position.CENTER_TOP
|
||||
(ad: any) => ad.position?.side === 'CENTER_TOP'
|
||||
);
|
||||
const centerBottomAds = allAds.filter(
|
||||
(ad: any) => ad.position === Position.CENTER_BOTTOM
|
||||
(ad: any) => ad.position?.side === 'CENTER_BOTTOM'
|
||||
);
|
||||
|
||||
// Randomize ads within each position and limit to maxAdsPerCarousel
|
||||
|
||||
295
src/app/(website)/components/poster-video-ad-sides.tsx
Normal file
295
src/app/(website)/components/poster-video-ad-sides.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { PosterAd } from "@/lib/types/posterAd";
|
||||
import { VideoAd, AdPosition } from "@/lib/types/videoAd";
|
||||
import { useState } from "react";
|
||||
|
||||
interface PosterVideoAdSidesProps {
|
||||
videoAds?: VideoAd[];
|
||||
posterAds?: PosterAd[];
|
||||
side: "left" | "right";
|
||||
}
|
||||
|
||||
// Video Ad Card Component
|
||||
function VideoAdCard({ ad }: { ad: VideoAd }) {
|
||||
if (!ad || !ad.image?.fileName) return null;
|
||||
|
||||
const mainCategory = ad.mainCategory?.name || "";
|
||||
const location =
|
||||
ad.city && ad.state ? `${ad.city}, ${ad.state}` : ad.city || ad.state || "";
|
||||
|
||||
// Get the video URL
|
||||
const videoUrl = `/api/images?imageName=${ad.image.fileName}`;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg shadow-md group w-full aspect-[1/1.2]">
|
||||
{/* Video */}
|
||||
<div className="relative h-full w-full overflow-hidden bg-gray-200">
|
||||
<video
|
||||
key={ad.id}
|
||||
className="object-cover w-full h-full transition-transform duration-300 group-hover:scale-105"
|
||||
playsInline
|
||||
preload="metadata"
|
||||
controls
|
||||
// onError={(e) => {
|
||||
// console.error("Video load error for ad:", ad.id, e);
|
||||
// }}
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<source src={videoUrl} type="video/webm" />
|
||||
<source src={videoUrl} type="video/ogg" />
|
||||
<img
|
||||
src={videoUrl}
|
||||
alt={mainCategory || "Video Ad"}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</video>
|
||||
|
||||
{/* Category badge - top right */}
|
||||
{mainCategory && (
|
||||
<div
|
||||
className="absolute top-2 right-2 rounded-full px-3 py-1 text-xs font-medium z-10"
|
||||
style={{
|
||||
backgroundColor: ad.mainCategory?.categories_color || "#8B5CF6",
|
||||
color: ad.mainCategory?.font_color || "white",
|
||||
}}
|
||||
>
|
||||
{mainCategory}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location badge - bottom left */}
|
||||
{location && (
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1 bg-black/70 rounded px-2 py-1 text-xs text-white z-10">
|
||||
<svg
|
||||
className="w-3 h-3 text-red-500"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced Poster Ad Card Component
|
||||
function EnhancedPosterAdCard({ ad }: { ad: PosterAd }) {
|
||||
if (!ad || !ad.image?.fileName) return null;
|
||||
|
||||
const mainCategory = ad.mainCategory?.name || "";
|
||||
const location =
|
||||
ad.city && ad.state ? `${ad.city}, ${ad.state}` : ad.city || ad.state || "";
|
||||
|
||||
// Get the image URL
|
||||
const imageUrl = `/api/images?imageName=${ad.image.fileName}`;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg shadow-md group w-full aspect-[1/1.2]">
|
||||
{/* Image */}
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<img
|
||||
key={ad.id}
|
||||
src={imageUrl || "/placeholder.svg"}
|
||||
alt={`${mainCategory}`.trim() || "Advertisement"}
|
||||
className="object-cover w-full h-full transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
console.error("Image load error:", e);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Category badge - top right */}
|
||||
{mainCategory && (
|
||||
<div
|
||||
className="absolute top-2 right-2 rounded-full px-3 py-1 text-xs font-medium z-10"
|
||||
style={{
|
||||
backgroundColor: ad.mainCategory?.categories_color || "#10B981",
|
||||
color: ad.mainCategory?.font_color || "white",
|
||||
}}
|
||||
>
|
||||
{mainCategory}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location badge - bottom left */}
|
||||
{location && (
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1 bg-black/70 rounded px-2 py-1 text-xs text-white z-10">
|
||||
<svg
|
||||
className="w-3 h-3 text-red-500"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Carousel Component for Position Group
|
||||
function AdCarousel({ ads, positionName }: { ads: Array<{ type: 'video' | 'poster', ad: VideoAd | PosterAd }>, positionName: string | number }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
if (ads.length === 0) return null;
|
||||
|
||||
const nextAd = () => {
|
||||
setCurrentIndex((prev) => (prev + 1) % ads.length);
|
||||
};
|
||||
|
||||
const prevAd = () => {
|
||||
setCurrentIndex((prev) => (prev - 1 + ads.length) % ads.length);
|
||||
};
|
||||
|
||||
const currentAd = ads[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
{/* Main Ad Display */}
|
||||
<div className="w-full">
|
||||
{currentAd?.type === "video" ? (
|
||||
<VideoAdCard ad={currentAd.ad as VideoAd} />
|
||||
) : (
|
||||
<EnhancedPosterAdCard ad={currentAd?.ad as PosterAd} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow Controls - Only show if more than 1 ad */}
|
||||
{ads.length > 1 && (
|
||||
<>
|
||||
{/* Left Arrow */}
|
||||
<button
|
||||
onClick={prevAd}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 z-20 transition-colors"
|
||||
aria-label="Previous ad"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Right Arrow */}
|
||||
<button
|
||||
onClick={nextAd}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 z-20 transition-colors"
|
||||
aria-label="Next ad"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dots Indicator */}
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1 z-20">
|
||||
{ads.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
className={`w-2 h-2 rounded-full transition-colors ${
|
||||
index === currentIndex ? 'bg-white' : 'bg-white/50'
|
||||
}`}
|
||||
aria-label={`Go to ad ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PosterVideoAdSides({
|
||||
videoAds = [],
|
||||
posterAds = [],
|
||||
side,
|
||||
}: PosterVideoAdSidesProps) {
|
||||
// Handle loading states
|
||||
if (!videoAds && !posterAds) {
|
||||
return <div className="flex items-center flex-col gap-5"></div>;
|
||||
}
|
||||
|
||||
// Define position numbers 1-6 for the given side
|
||||
const positions = [1, 2, 3, 4, 5, 6];
|
||||
const targetSide = side === "left" ? "LEFT_SIDE" : "RIGHT_SIDE";
|
||||
|
||||
// Helper function to shuffle array
|
||||
const shuffleArray = <T,>(array: T[]): T[] => {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
// Group ads by position and combine video/poster ads
|
||||
const getAdsForPosition = (positionNum: number) => {
|
||||
const positionAds: Array<{ type: 'video' | 'poster', ad: VideoAd | PosterAd }> = [];
|
||||
|
||||
// Get all video ads for this position and side (video ads can only be LEFT_SIDE or RIGHT_SIDE)
|
||||
const videoAdsForPosition = videoAds?.filter((ad) =>
|
||||
ad.position?.side === targetSide && ad.position?.position === positionNum
|
||||
) || [];
|
||||
videoAdsForPosition.forEach(ad => {
|
||||
positionAds.push({ type: 'video', ad });
|
||||
});
|
||||
|
||||
// Get all poster ads for this position and side
|
||||
const posterAdsForPosition = posterAds?.filter((ad) =>
|
||||
ad.position?.side === targetSide && ad.position?.position === positionNum
|
||||
) || [];
|
||||
posterAdsForPosition.forEach(ad => {
|
||||
positionAds.push({ type: 'poster', ad });
|
||||
});
|
||||
|
||||
// Randomize the ads within this position
|
||||
const shuffledAds = shuffleArray(positionAds);
|
||||
|
||||
// Return max 5 ads
|
||||
return shuffledAds.slice(0, 5);
|
||||
};
|
||||
|
||||
// Get all ads grouped by position
|
||||
const adsGroupedByPosition = positions.map(positionNum => ({
|
||||
position: positionNum,
|
||||
ads: getAdsForPosition(positionNum)
|
||||
})).filter(group => group.ads.length > 0);
|
||||
|
||||
// Debug logging - remove in production
|
||||
console.log(`${side} side ads:`, {
|
||||
videoAdsCount: videoAds?.length || 0,
|
||||
posterAdsCount: posterAds?.length || 0,
|
||||
positionGroups: adsGroupedByPosition.length,
|
||||
totalAdsToRender: adsGroupedByPosition.reduce((sum, group) => sum + group.ads.length, 0),
|
||||
});
|
||||
|
||||
// If no ads to render, return empty div
|
||||
if (adsGroupedByPosition.length === 0) {
|
||||
return <div className="flex items-center flex-col gap-5"></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-col gap-5">
|
||||
{adsGroupedByPosition.map((positionGroup, groupIndex) => (
|
||||
<div key={`${side}-${positionGroup.position}-${groupIndex}`} className="w-full">
|
||||
<AdCarousel
|
||||
ads={positionGroup.ads}
|
||||
positionName={`${targetSide}-${positionGroup.position}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ export function PosterAdCard({ ad, className }: PosterAdCardProps) {
|
||||
if (!ad) return null;
|
||||
|
||||
const mainCategory = ad.mainCategory?.name || "";
|
||||
const categoryOne = ad.categoryOne?.name || "";
|
||||
const categoryTwo = ad.categoryTwo?.name || "";
|
||||
const categoryThree = ad.categoryThree?.name || "";
|
||||
|
||||
const location =
|
||||
ad.city && ad.state ? `${ad.city}, ${ad.state}` : ad.city || ad.state || "";
|
||||
@@ -60,7 +63,7 @@ export function PosterAdCard({ ad, className }: PosterAdCardProps) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* {categoryOne && (
|
||||
{categoryOne && (
|
||||
<span
|
||||
className="rounded bg-black/80 px-2 py-1 text-xs font-medium text-white"
|
||||
style={{
|
||||
@@ -91,7 +94,7 @@ export function PosterAdCard({ ad, className }: PosterAdCardProps) {
|
||||
>
|
||||
{categoryThree}
|
||||
</span>
|
||||
)} */}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { PosterAdCard } from "./video-ad-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface PosterAdCarouselProps {
|
||||
ads: any[];
|
||||
maxAds?: number;
|
||||
}
|
||||
|
||||
export function PosterAdCarousel({ ads, maxAds = 3 }: PosterAdCarouselProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
// Limit the number of ads to maxAds
|
||||
const limitedAds = ads.slice(0, maxAds);
|
||||
|
||||
// Function to handle next slide
|
||||
const nextSlide = useCallback(() => {
|
||||
if (limitedAds.length <= 1) return;
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % limitedAds.length);
|
||||
}, [limitedAds.length]);
|
||||
|
||||
// Function to handle previous slide
|
||||
const prevSlide = useCallback(() => {
|
||||
if (limitedAds.length <= 1) return;
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === 0 ? limitedAds.length - 1 : prevIndex - 1
|
||||
);
|
||||
}, [limitedAds.length]);
|
||||
|
||||
// Auto-advance slides every 5 seconds
|
||||
useEffect(() => {
|
||||
if (limitedAds.length <= 1) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
nextSlide();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [nextSlide, limitedAds.length]);
|
||||
|
||||
// Reset currentIndex if ads change
|
||||
useEffect(() => {
|
||||
setCurrentIndex(0);
|
||||
}, [ads]);
|
||||
|
||||
if (!ads || ads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the current ad to display
|
||||
const currentAd = limitedAds[currentIndex];
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden rounded-lg">
|
||||
{/* Carousel container */}
|
||||
<div className="relative">
|
||||
{/* Current ad */}
|
||||
<div className="w-full">
|
||||
<PosterAdCard ad={currentAd} className="h-full" />
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows - only show if we have more than 1 ad */}
|
||||
{limitedAds.length > 1 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute left-2 top-1/2 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-white/80 shadow-md hover:bg-white"
|
||||
onClick={prevSlide}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-white/80 shadow-md hover:bg-white"
|
||||
onClick={nextSlide}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dots indicator - only show if we have more than 1 ad */}
|
||||
{limitedAds.length > 1 && (
|
||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center space-x-2">
|
||||
{limitedAds.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
index === currentIndex ? "bg-white" : "bg-white/50"
|
||||
}`}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
>
|
||||
<span className="sr-only">Go to slide {index + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,12 @@ import api from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { VideoAd } from "@/lib/types/videoAd";
|
||||
import type { PosterAd } from "@/lib/types/posterAd";
|
||||
import { Position } from "@/lib/enum/position";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import LineAds from "../line-ad/line-ads";
|
||||
import dynamic from "next/dynamic";
|
||||
import PosterAds from "../poster-ad/poster-ads";
|
||||
import Zoom from "react-medium-image-zoom";
|
||||
|
||||
// Dynamically import ReactPlayer to avoid SSR issues
|
||||
const ReactPlayer = dynamic(() => import("react-player/lazy"), { ssr: false });
|
||||
@@ -66,14 +66,16 @@ function shuffleArray<T>(array: T[]): T[] {
|
||||
// Component for a position with multiple ads
|
||||
interface PositionAdProps {
|
||||
ads: Ad[];
|
||||
position: Position;
|
||||
side: string;
|
||||
positionNumber: number;
|
||||
maxAds?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PositionAd: React.FC<PositionAdProps> = ({
|
||||
ads,
|
||||
position,
|
||||
side,
|
||||
positionNumber,
|
||||
maxAds = 5,
|
||||
className,
|
||||
}) => {
|
||||
@@ -85,8 +87,7 @@ const PositionAd: React.FC<PositionAdProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const positionIndex = Object.values(Position).indexOf(position);
|
||||
setShowVideo(positionIndex % 2 === 0);
|
||||
setShowVideo(positionNumber % 2 === 0);
|
||||
|
||||
// Randomize ads (filtering already done at parent level)
|
||||
if (ads && ads.length > 0) {
|
||||
@@ -94,7 +95,7 @@ const PositionAd: React.FC<PositionAdProps> = ({
|
||||
} else {
|
||||
setRandomizedAds([]);
|
||||
}
|
||||
}, [position, ads, maxAds]);
|
||||
}, [side, positionNumber, ads, maxAds]);
|
||||
|
||||
// Force video re-render when currentIndex changes
|
||||
useEffect(() => {
|
||||
@@ -102,12 +103,9 @@ const PositionAd: React.FC<PositionAdProps> = ({
|
||||
}, [currentIndex]);
|
||||
|
||||
if (!randomizedAds || randomizedAds.length === 0) {
|
||||
const posterIndex =
|
||||
Object.values(Position).indexOf(position) % SAMPLE_POSTERS.length;
|
||||
const categoryIndex =
|
||||
Object.values(Position).indexOf(position) % SAMPLE_CATEGORIES.length;
|
||||
const locationIndex =
|
||||
Object.values(Position).indexOf(position) % SAMPLE_LOCATIONS.length;
|
||||
const posterIndex = positionNumber % SAMPLE_POSTERS.length;
|
||||
const categoryIndex = positionNumber % SAMPLE_CATEGORIES.length;
|
||||
const locationIndex = positionNumber % SAMPLE_LOCATIONS.length;
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
@@ -301,59 +299,45 @@ const PositionAd: React.FC<PositionAdProps> = ({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 pointer-events-none z-20">
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1 max-w-[calc(50%-1rem)]">
|
||||
{currentAd.mainCategory && (
|
||||
<Badge
|
||||
className="text-xs pointer-events-auto hover:scale-105 transition-transform duration-200 shadow-md backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor:
|
||||
currentAd.mainCategory.category_heading_font_color,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{currentAd.mainCategory.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge className="absolute bottom-12 left-2 bg-black/70 text-white text-xs backdrop-blur-sm shadow-md">
|
||||
📍 {currentAd.city}, {currentAd.state}
|
||||
</Badge>
|
||||
{/* Small top bar: main category left, location right */}
|
||||
<div className="absolute top-0 left-0 right-0 flex justify-between items-center px-2 py-1 text-xs text-white font-medium bg-black/60 z-30 rounded-t-md">
|
||||
<span className="truncate max-w-[60%]">
|
||||
{currentAd.mainCategory?.name}
|
||||
</span>
|
||||
<span className="ml-2 whitespace-nowrap text-white font-medium">
|
||||
{currentAd.city && currentAd.state
|
||||
? `${currentAd.city}, ${currentAd.state}`
|
||||
: currentAd.city || currentAd.state || ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={mediaUrl || "/placeholder.svg"}
|
||||
alt={`${currentAd.mainCategory?.name} - ${
|
||||
currentAd.categoryOne?.name || ""
|
||||
}`}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = "/placeholder.svg";
|
||||
}}
|
||||
/>
|
||||
<Zoom>
|
||||
<img
|
||||
src={mediaUrl || "/placeholder.svg"}
|
||||
alt={`${currentAd.mainCategory?.name} - ${
|
||||
currentAd.categoryOne?.name || ""
|
||||
}`}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = "/placeholder.svg";
|
||||
}}
|
||||
/>
|
||||
</Zoom>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent pointer-events-none">
|
||||
<div className="absolute top-2 right-2 flex flex-col gap-1 max-w-[calc(50%-1rem)]">
|
||||
{currentAd.mainCategory && (
|
||||
<Badge
|
||||
className="text-xs pointer-events-auto hover:scale-105 transition-transform duration-200 shadow-md backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor:
|
||||
currentAd.mainCategory.category_heading_font_color,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{currentAd.mainCategory.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge className="absolute bottom-2 left-2 bg-black/70 text-white text-xs backdrop-blur-sm shadow-md">
|
||||
📍 {currentAd.city}, {currentAd.state}
|
||||
</Badge>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent pointer-events-none" />
|
||||
{/* Small top bar: main category left, location right */}
|
||||
<div className="absolute top-0 left-0 right-0 flex justify-between items-center px-2 py-1 text-xs text-white font-medium bg-black/60 z-30 rounded-t-md">
|
||||
<span className="truncate max-w-[60%]">
|
||||
{currentAd.mainCategory?.name}
|
||||
</span>
|
||||
<span className="ml-2 whitespace-nowrap text-white font-medium">
|
||||
{currentAd.city && currentAd.state
|
||||
? `${currentAd.city}, ${currentAd.state}`
|
||||
: currentAd.city || currentAd.state || ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -416,57 +400,46 @@ export default function VideoPosterAd({
|
||||
// Get all ads (video and poster) and filter out CENTER_TOP and CENTER_BOTTOM
|
||||
const allAds = [...(videoData || []), ...(posterData || [])].filter(
|
||||
(ad) =>
|
||||
ad.position !== Position.CENTER_TOP &&
|
||||
ad.position !== Position.CENTER_BOTTOM
|
||||
ad.position?.side !== 'CENTER_TOP' &&
|
||||
ad.position?.side !== 'CENTER_BOTTOM'
|
||||
);
|
||||
|
||||
console.log("All ads after filtering:", allAds);
|
||||
console.log("Video ads:", videoData?.length || 0);
|
||||
console.log("Poster ads:", posterData?.length || 0);
|
||||
|
||||
// Initialize empty arrays for each position
|
||||
const adsByPosition: Record<Position, Ad[]> = {} as Record<Position, Ad[]>;
|
||||
const allPositions = [
|
||||
Position.LEFT_TOP_ONE,
|
||||
Position.LEFT_TOP_TWO,
|
||||
Position.LEFT_TOP_THREE,
|
||||
Position.LEFT_TOP_FOUR,
|
||||
Position.LEFT_BOTTOM_ONE,
|
||||
Position.LEFT_BOTTOM_TWO,
|
||||
Position.LEFT_BOTTOM_THREE,
|
||||
Position.LEFT_BOTTOM_FOUR,
|
||||
Position.RIGHT_TOP_ONE,
|
||||
Position.RIGHT_TOP_TWO,
|
||||
Position.RIGHT_TOP_THREE,
|
||||
Position.RIGHT_TOP_FOUR,
|
||||
Position.RIGHT_BOTTOM_ONE,
|
||||
Position.RIGHT_BOTTOM_TWO,
|
||||
Position.RIGHT_BOTTOM_THREE,
|
||||
Position.RIGHT_BOTTOM_FOUR,
|
||||
];
|
||||
// Initialize empty arrays for each position combination
|
||||
const adsByPosition: Record<string, Ad[]> = {};
|
||||
|
||||
// Create position keys for LEFT_SIDE and RIGHT_SIDE with positions 1-6
|
||||
const positionKeys = [];
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
positionKeys.push(`LEFT_SIDE_${i}`);
|
||||
positionKeys.push(`RIGHT_SIDE_${i}`);
|
||||
}
|
||||
|
||||
allPositions.forEach((pos) => {
|
||||
adsByPosition[pos] = [];
|
||||
positionKeys.forEach((key) => {
|
||||
adsByPosition[key] = [];
|
||||
});
|
||||
|
||||
// Distribute ads to their exact positions only
|
||||
if (allAds.length > 0) {
|
||||
allAds.forEach((ad) => {
|
||||
const adPosition = ad.position as string;
|
||||
if (Object.values(Position).includes(adPosition as Position)) {
|
||||
const position = adPosition as Position;
|
||||
if (allPositions.includes(position)) {
|
||||
adsByPosition[position].push(ad);
|
||||
if (ad.position?.side && ad.position?.position) {
|
||||
const positionKey = `${ad.position.side}_${ad.position.position}`;
|
||||
if (adsByPosition[positionKey]) {
|
||||
adsByPosition[positionKey].push(ad);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to render a single ad for a position
|
||||
const renderAdSlot = (position: Position) => {
|
||||
const positionAds = adsByPosition[position] || [];
|
||||
const renderAdSlot = (side: string, positionNumber: number) => {
|
||||
const positionKey = `${side}_${positionNumber}`;
|
||||
const positionAds = adsByPosition[positionKey] || [];
|
||||
console.log(
|
||||
`Position ${position}:`,
|
||||
`Position ${positionKey}:`,
|
||||
positionAds.length,
|
||||
"ads",
|
||||
positionAds
|
||||
@@ -475,7 +448,8 @@ export default function VideoPosterAd({
|
||||
<div className="w-full h-full relative">
|
||||
<PositionAd
|
||||
ads={positionAds}
|
||||
position={position}
|
||||
side={side}
|
||||
positionNumber={positionNumber}
|
||||
maxAds={10}
|
||||
className="w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
@@ -487,33 +461,23 @@ export default function VideoPosterAd({
|
||||
<div className=" py-5 flex flex-col md:grid md:grid-cols-12 gap-3 md:gap-5 max-w-[1800px] mx-auto w-full px-2 md:px-4">
|
||||
{/* Left Column: 4 ad slots, each fills 1/4 of the column height */}
|
||||
<div className="col-span-2 space-y-3">
|
||||
<div className="col-span-2 h-[calc(100vh-65px)] min-h-0 flex flex-col gap-3 overflow-hidden">
|
||||
{[
|
||||
Position.LEFT_TOP_ONE,
|
||||
Position.LEFT_TOP_TWO,
|
||||
Position.LEFT_TOP_THREE,
|
||||
Position.LEFT_TOP_FOUR,
|
||||
].map((pos) => (
|
||||
<div className="col-span-2 h-[calc(100vh-65px)] min-h-0 grid grid-cols-2 md:grid-cols-1 gap-3 overflow-hidden">
|
||||
{[1, 2, 3].map((positionNumber) => (
|
||||
<div
|
||||
key={pos}
|
||||
key={`LEFT_SIDE_${positionNumber}`}
|
||||
className="flex-1 min-h-0 w-full max-w-full overflow-hidden"
|
||||
>
|
||||
{renderAdSlot(pos)}
|
||||
{renderAdSlot('LEFT_SIDE', positionNumber)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 h-[calc(100vh-65px)] min-h-0 flex flex-col gap-3 overflow-hidden">
|
||||
{[
|
||||
Position.LEFT_BOTTOM_ONE,
|
||||
Position.LEFT_BOTTOM_TWO,
|
||||
Position.LEFT_BOTTOM_THREE,
|
||||
Position.LEFT_BOTTOM_FOUR,
|
||||
].map((pos) => (
|
||||
<div className="col-span-2 h-[calc(100vh-65px)] min-h-0 grid grid-cols-2 md:grid-cols-1 gap-3 overflow-hidden">
|
||||
{[4, 5, 6].map((positionNumber) => (
|
||||
<div
|
||||
key={pos}
|
||||
key={`LEFT_SIDE_${positionNumber}`}
|
||||
className="flex-1 min-h-0 w-full max-w-full overflow-hidden"
|
||||
>
|
||||
{renderAdSlot(pos)}
|
||||
{renderAdSlot('LEFT_SIDE', positionNumber)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -521,41 +485,32 @@ export default function VideoPosterAd({
|
||||
|
||||
{/* Center Line Ads */}
|
||||
<div className="col-span-8 w-full ">
|
||||
{children}
|
||||
<PosterAds>
|
||||
<LineAds />
|
||||
{children}
|
||||
</PosterAds>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Right Column: 4 ad slots, each fills 1/4 of the column height */}
|
||||
<div className="col-span-2 space-y-3">
|
||||
<div className="col-span-2 h-[calc(100vh-65px)] min-h-0 flex flex-col gap-3 overflow-hidden">
|
||||
{[
|
||||
Position.RIGHT_TOP_ONE,
|
||||
Position.RIGHT_TOP_TWO,
|
||||
Position.RIGHT_TOP_THREE,
|
||||
Position.RIGHT_TOP_FOUR,
|
||||
].map((pos) => (
|
||||
<div className="col-span-2 h-[calc(100vh-65px)] min-h-0 grid grid-cols-2 md:grid-cols-1 gap-3 overflow-hidden">
|
||||
{[1, 2, 3].map((positionNumber) => (
|
||||
<div
|
||||
key={pos}
|
||||
key={`RIGHT_SIDE_${positionNumber}`}
|
||||
className="flex-1 min-h-0 w-full max-w-full overflow-hidden"
|
||||
>
|
||||
{renderAdSlot(pos)}
|
||||
{renderAdSlot('RIGHT_SIDE', positionNumber)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col-span-2 h-[calc(100vh-65px)] min-h-0 flex flex-col gap-3 overflow-hidden">
|
||||
{[
|
||||
Position.RIGHT_BOTTOM_ONE,
|
||||
Position.RIGHT_BOTTOM_TWO,
|
||||
Position.RIGHT_BOTTOM_THREE,
|
||||
Position.RIGHT_BOTTOM_FOUR,
|
||||
].map((pos) => (
|
||||
<div className="col-span-2 h-[calc(100vh-65px)] min-h-0 grid grid-cols-2 md:grid-cols-1 gap-3 overflow-hidden">
|
||||
{[4, 5, 6].map((positionNumber) => (
|
||||
<div
|
||||
key={pos}
|
||||
key={`RIGHT_SIDE_${positionNumber}`}
|
||||
className="flex-1 min-h-0 w-full max-w-full overflow-hidden"
|
||||
>
|
||||
{renderAdSlot(pos)}
|
||||
{renderAdSlot('RIGHT_SIDE', positionNumber)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
272
src/app/(website)/contact/page.tsx
Normal file
272
src/app/(website)/contact/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Clock,
|
||||
Globe,
|
||||
AlertCircle,
|
||||
Users,
|
||||
ShoppingCart
|
||||
} from "lucide-react";
|
||||
import api from "@/lib/api";
|
||||
|
||||
export default function ContactPage() {
|
||||
const { data: contactData, isLoading, error } = useQuery({
|
||||
queryKey: ["contactPage"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/contact-page");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !contactData) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">Contact Us</h1>
|
||||
<p className="text-gray-600">Contact information not available at the moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-20 px-4 pb-12">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-4">Contact Us</h1>
|
||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
Get in touch with {contactData.companyName || "us"}. We're here to help!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-12">
|
||||
{/* Primary Contact Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Primary Contact</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{contactData.email && (
|
||||
<div className="flex items-start gap-4">
|
||||
<Mail className="h-5 w-5 text-blue-600 mt-1" />
|
||||
<div>
|
||||
<p className="font-medium">Email Address</p>
|
||||
<a
|
||||
href={`mailto:${contactData.email}`}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{contactData.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contactData.phone && (
|
||||
<div className="flex items-start gap-4">
|
||||
<Phone className="h-5 w-5 text-green-600 mt-1" />
|
||||
<div>
|
||||
<p className="font-medium">Phone Number</p>
|
||||
<a
|
||||
href={`tel:${contactData.phone}`}
|
||||
className="text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{contactData.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contactData.alternatePhone && (
|
||||
<div className="flex items-start gap-4">
|
||||
<Phone className="h-5 w-5 text-green-600 mt-1" />
|
||||
<div>
|
||||
<p className="font-medium">Alternate Phone</p>
|
||||
<a
|
||||
href={`tel:${contactData.alternatePhone}`}
|
||||
className="text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
{contactData.alternatePhone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contactData.websiteUrl && (
|
||||
<div className="flex items-start gap-4">
|
||||
<Globe className="h-5 w-5 text-purple-600 mt-1" />
|
||||
<div>
|
||||
<p className="font-medium">Website</p>
|
||||
<a
|
||||
href={contactData.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{contactData.websiteUrl}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Address Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Address</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-4">
|
||||
<MapPin className="h-5 w-5 text-red-600 mt-1" />
|
||||
<div>
|
||||
<p className="font-medium mb-2">Office Location</p>
|
||||
<div className="text-gray-700 space-y-1">
|
||||
{contactData.address && <p>{contactData.address}</p>}
|
||||
<p>
|
||||
{[contactData.city, contactData.state, contactData.postalCode]
|
||||
.filter(Boolean).join(', ')}
|
||||
</p>
|
||||
{contactData.country && <p>{contactData.country}</p>}
|
||||
</div>
|
||||
|
||||
{contactData.coordinates && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Coordinates: {contactData.coordinates.latitude}, {contactData.coordinates.longitude}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Specialized Contact Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
{contactData.supportEmail && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
Support
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-2">For technical support and help</p>
|
||||
<a
|
||||
href={`mailto:${contactData.supportEmail}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{contactData.supportEmail}
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{contactData.salesEmail && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<ShoppingCart className="h-5 w-5 text-green-600" />
|
||||
Sales
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-2">For sales inquiries and partnerships</p>
|
||||
<a
|
||||
href={`mailto:${contactData.salesEmail}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{contactData.salesEmail}
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{contactData.emergencyContact && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
Emergency
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 mb-2">For urgent matters</p>
|
||||
<p className="text-gray-700 font-medium">{contactData.emergencyContact}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Business Hours */}
|
||||
{contactData.businessHours && (
|
||||
<Card className="mb-12">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-orange-600" />
|
||||
Business Hours
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Object.entries(contactData.businessHours).map(([day, hours]) => (
|
||||
<div key={day} className="text-center p-3 border rounded-lg">
|
||||
<p className="font-medium capitalize">{day}</p>
|
||||
<p className="text-sm text-gray-600">{hours as string}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Social Media Links */}
|
||||
{contactData.socialMediaLinks && contactData.socialMediaLinks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Follow Us</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{contactData.socialMediaLinks.map((link: string, index: number) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 border border-blue-200 transition-colors"
|
||||
>
|
||||
Social Media {index + 1}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
src/app/(website)/faq/page.tsx
Normal file
210
src/app/(website)/faq/page.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { HelpCircle, Mail, Phone, Filter, X } from "lucide-react";
|
||||
import api from "@/lib/api";
|
||||
|
||||
export default function FAQPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
||||
|
||||
const { data: faqData, isLoading, error } = useQuery({
|
||||
queryKey: ["faq"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/faq");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !faqData) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">Frequently Asked Questions</h1>
|
||||
<p className="text-gray-600">FAQ information not available at the moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter questions by category and active status
|
||||
const filteredQuestions = faqData.questions?.filter((q: any) => {
|
||||
if (!q.isActive) return false;
|
||||
if (!selectedCategory) return true;
|
||||
return q.category === selectedCategory;
|
||||
}).sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
|
||||
|
||||
// Get unique categories
|
||||
const categories = faqData.categories ||
|
||||
[...new Set(faqData.questions?.map((q: any) => q.category).filter(Boolean))];
|
||||
|
||||
return (
|
||||
<div className="pt-20 px-4 pb-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<HelpCircle className="h-8 w-8 text-blue-600" />
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
</div>
|
||||
{faqData.introduction && (
|
||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
{faqData.introduction}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter className="h-5 w-5 text-gray-600" />
|
||||
<h2 className="text-lg font-semibold">Filter by Category</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={selectedCategory === "" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("")}
|
||||
className="mb-2"
|
||||
>
|
||||
All Categories
|
||||
</Button>
|
||||
{categories.map((category: string) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className="mb-2"
|
||||
>
|
||||
{category}
|
||||
{selectedCategory === category && (
|
||||
<X
|
||||
className="ml-2 h-3 w-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedCategory("");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAQ Questions */}
|
||||
{filteredQuestions && filteredQuestions.length > 0 ? (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6">
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{filteredQuestions.map((faq: any, index: number) => (
|
||||
<AccordionItem key={index} value={`item-${index}`}>
|
||||
<AccordionTrigger className="text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{faq.category}
|
||||
</Badge>
|
||||
<span>{faq.question}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4">
|
||||
<div
|
||||
className="prose max-w-none text-gray-700"
|
||||
dangerouslySetInnerHTML={{ __html: faq.answer }}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<HelpCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">
|
||||
{selectedCategory
|
||||
? `No questions found in the "${selectedCategory}" category.`
|
||||
: "No FAQ questions available at the moment."
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Contact Information for Additional Help */}
|
||||
{faqData.contactInfo && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Still Need Help?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-4">
|
||||
If you couldn't find the answer to your question, feel free to contact us directly.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{faqData.contactInfo.email && (
|
||||
<div className="flex items-center gap-3 p-4 bg-blue-50 rounded-lg">
|
||||
<Mail className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Email Us</p>
|
||||
<a
|
||||
href={`mailto:${faqData.contactInfo.email}`}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
{faqData.contactInfo.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{faqData.contactInfo.phone && (
|
||||
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
|
||||
<Phone className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Call Us</p>
|
||||
<a
|
||||
href={`tel:${faqData.contactInfo.phone}`}
|
||||
className="text-green-600 hover:text-green-800 text-sm"
|
||||
>
|
||||
{faqData.contactInfo.phone}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Navbar from "@/components/navbar";
|
||||
import { Suspense } from "react";
|
||||
import LineAds from "./components/line-ad/line-ads";
|
||||
import PosterAds from "./components/poster-ad/poster-ads";
|
||||
import VideoPosterAd from "./components/video-ad/video-ads";
|
||||
import "plyr-react/plyr.css";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
interface ContactInfo {
|
||||
companyName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
alternatePhone?: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
coordinates?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
socialMediaLinks: string[];
|
||||
businessHours: {
|
||||
monday: string;
|
||||
tuesday: string;
|
||||
wednesday: string;
|
||||
thursday: string;
|
||||
friday: string;
|
||||
saturday: string;
|
||||
sunday: string;
|
||||
};
|
||||
supportEmail?: string;
|
||||
salesEmail?: string;
|
||||
emergencyContact?: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export default function RegisterLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [contactInfo, setContactInfo] = useState<ContactInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchContactInfo = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${
|
||||
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"
|
||||
}/server/configurations/contact-page`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setContactInfo(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch contact info:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContactInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<Suspense fallback={null}>
|
||||
<div className="min-h-screen">{children}</div>
|
||||
{/* <Suspense fallback={null}>
|
||||
<VideoPosterAd>{children}</VideoPosterAd>
|
||||
</Suspense>
|
||||
</Suspense> */}
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12 mt-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{/* Company Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="PaisaAds - Broadcast Brilliance"
|
||||
width={160}
|
||||
height={53}
|
||||
className="h-10 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">
|
||||
Your trusted platform for classified advertisements. Connect
|
||||
buyers and sellers across various categories with ease and
|
||||
reliability.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
{contactInfo?.socialMediaLinks &&
|
||||
contactInfo.socialMediaLinks.length > 0 &&
|
||||
contactInfo.socialMediaLinks
|
||||
.slice(0, 3)
|
||||
.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="sr-only">Social Media</span>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0C5.374 0 0 5.373 0 12s5.374 12 12 12 12-5.373 12-12S18.626 0 12 0zm5.568 8.16c-.169 1.858-.896 3.49-2.068 4.663-1.173 1.172-2.805 1.899-4.663 2.068-1.858-.169-3.49-.896-4.663-2.068C4.001 11.65 3.274 10.018 3.105 8.16c.169-1.858.896-3.49 2.068-4.663C6.346 2.324 7.978 1.597 9.836 1.428c1.858.169 3.49.896 4.663 2.069 1.172 1.173 1.899 2.805 2.069 4.663z" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white">Quick Links</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/search"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Browse Ads
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/dashboard/post-ad"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Post Ad
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
My Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/about"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
About Us
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Support */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white">Support</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link
|
||||
href="/help"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Help Center
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/faq"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
FAQ
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/privacy-policy"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/terms"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Terms & Conditions
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white">Contact Info</h3>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-700 rounded animate-pulse"></div>
|
||||
<div className="h-4 bg-gray-700 rounded animate-pulse"></div>
|
||||
<div className="h-4 bg-gray-700 rounded animate-pulse"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{contactInfo && (
|
||||
<>
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{contactInfo.address && `${contactInfo.address}, `}
|
||||
{contactInfo.city && `${contactInfo.city}, `}
|
||||
{contactInfo.state && `${contactInfo.state} `}
|
||||
{contactInfo.postalCode &&
|
||||
`${contactInfo.postalCode}, `}
|
||||
{contactInfo.country || "Nepal"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="space-y-1">
|
||||
<p className="text-gray-400 text-sm">
|
||||
{contactInfo.email}
|
||||
</p>
|
||||
{contactInfo.supportEmail && (
|
||||
<p className="text-gray-400 text-xs">
|
||||
Support: {contactInfo.supportEmail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="space-y-1">
|
||||
<p className="text-gray-400 text-sm">
|
||||
{contactInfo.phone}
|
||||
</p>
|
||||
{contactInfo.alternatePhone && (
|
||||
<p className="text-gray-400 text-xs">
|
||||
Alt: {contactInfo.alternatePhone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contactInfo.websiteUrl && (
|
||||
<div className="flex items-start space-x-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400 mt-0.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9m0 9c-5 0-9-4-9-9s4-9 9-9"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<Link
|
||||
href={contactInfo.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{contactInfo.websiteUrl.replace(
|
||||
/^https?:\/\//,
|
||||
""
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="border-t border-gray-800 mt-8 pt-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<div className="text-center md:text-left">
|
||||
<p className="text-sm text-gray-400">
|
||||
© {new Date().getFullYear()}{" "}
|
||||
{contactInfo?.companyName || "PaisaAds"}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center md:text-right">
|
||||
<p className="text-xs text-gray-500">
|
||||
Developed and maintained by{" "}
|
||||
<Link
|
||||
href="https://mobifish.in"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 transition-colors duration-200"
|
||||
>
|
||||
MobiFish
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Position } from "@/lib/enum/position";
|
||||
import axios from "axios";
|
||||
|
||||
interface PosterAd {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
position: Position;
|
||||
}
|
||||
import api from "@/lib/api";
|
||||
import { PosterAd } from "@/lib/types/posterAd";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import PosterVideoAdSides from "./components/poster-video-ad-sides";
|
||||
import PosterAdCenterBottom from "./components/poster-ad-center-bottom";
|
||||
import LineAds from "./components/line-ad/line-ads";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export default function Home() {
|
||||
return <></>;
|
||||
const data = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ["line-ads"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get("line-ad/today");
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
{
|
||||
queryKey: ["video-ads"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get("video-ad/today");
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
{
|
||||
queryKey: ["poster-ads"],
|
||||
queryFn: async () => {
|
||||
const response = await api.get("poster-ad/today");
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
],
|
||||
combine(result) {
|
||||
// console.log("Combined Result:", result);
|
||||
const [lineAds, videoAds, posterAds] = result;
|
||||
|
||||
const filteredPosterAds = posterAds.data?.filter((ad: PosterAd) => {
|
||||
return (
|
||||
ad.position?.side !== "CENTER_BOTTOM" &&
|
||||
ad.position?.side !== "CENTER_TOP"
|
||||
);
|
||||
});
|
||||
return {
|
||||
lineAds: lineAds.data || [],
|
||||
filteredPosterAds: filteredPosterAds || [],
|
||||
videoAds: videoAds.data || [],
|
||||
centerBottomPosterAd:
|
||||
posterAds.data?.find(
|
||||
(ad: PosterAd) => ad.position?.side === "CENTER_BOTTOM"
|
||||
) || null,
|
||||
centerTopPosterAd:
|
||||
posterAds.data?.find(
|
||||
(ad: PosterAd) => ad.position?.side === "CENTER_TOP"
|
||||
) || null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pt-5 px-10 grid grid-cols-12 gap-5">
|
||||
<div className="col-span-2">
|
||||
<PosterVideoAdSides
|
||||
side="left"
|
||||
posterAds={data.filteredPosterAds}
|
||||
videoAds={data.videoAds}
|
||||
/>
|
||||
</div>
|
||||
<PosterAdCenterBottom
|
||||
topAds={data.centerTopPosterAd}
|
||||
bottomAds={data.centerBottomPosterAd}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<LineAds />
|
||||
</Suspense>
|
||||
</PosterAdCenterBottom>
|
||||
|
||||
<div className="col-span-2">
|
||||
<PosterVideoAdSides
|
||||
side="right"
|
||||
posterAds={data.filteredPosterAds}
|
||||
videoAds={data.videoAds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
163
src/app/(website)/privacy-policy/page.tsx
Normal file
163
src/app/(website)/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Shield, Calendar, User, FileText } from "lucide-react";
|
||||
import api from "@/lib/api";
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
const { data: privacyData, isLoading, error } = useQuery({
|
||||
queryKey: ["privacyPolicy"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/privacy-policy");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !privacyData) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">Privacy Policy</h1>
|
||||
<p className="text-gray-600">Privacy policy not available at the moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-20 px-4 pb-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<Shield className="h-8 w-8 text-green-600" />
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
Your privacy is important to us. This policy outlines how we collect, use, and protect your information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
{privacyData.version && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Version</p>
|
||||
<p className="font-semibold">{privacyData.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{privacyData.effectiveDate && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Effective Date</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(privacyData.effectiveDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{privacyData.lastUpdated && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Updated</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(privacyData.lastUpdated).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy Policy Content */}
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6">
|
||||
{privacyData.content ? (
|
||||
<div
|
||||
className="prose prose-lg max-w-none text-gray-700 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: privacyData.content }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Shield className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Privacy policy content is being updated.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Important Notice */}
|
||||
<Card className="bg-green-50 border-green-200">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="h-5 w-5 text-green-600 mt-1 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-green-900 mb-2">Your Privacy Matters</h3>
|
||||
<p className="text-sm text-green-800">
|
||||
We are committed to protecting your personal information and your right to privacy.
|
||||
If you have any questions or concerns about our policy or our practices regarding
|
||||
your personal information, please contact us.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer Information */}
|
||||
<Card className="mt-8 bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
By using our service, you acknowledge that you have read and understood this privacy policy.
|
||||
</p>
|
||||
{privacyData.updatedBy && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-gray-500">
|
||||
<User className="h-3 w-3" />
|
||||
<span>Last updated by: {privacyData.updatedBy}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,274 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Search, MapPin, X } from "lucide-react";
|
||||
import {
|
||||
StateSelect,
|
||||
CitySelect,
|
||||
CountrySelect,
|
||||
} from "react-country-state-city";
|
||||
import "react-country-state-city/dist/react-country-state-city.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import api from "@/lib/api";
|
||||
|
||||
// Define the form schema
|
||||
const searchFormSchema = z.object({
|
||||
categoryId: z.string(),
|
||||
stateId: z.number().optional(),
|
||||
cityId: z.number().optional(),
|
||||
countryId: z.number().optional(),
|
||||
});
|
||||
|
||||
type SearchFormValues = z.infer<typeof searchFormSchema>;
|
||||
import { Suspense } from "react";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const [countryId, setCountryId] = useState(101); // India
|
||||
const [stateId, setStateId] = useState(0);
|
||||
const [cityId, setCityId] = useState(0);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const params = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const countryId = params.get("countryId");
|
||||
if (countryId) {
|
||||
setCountryId(parseInt(countryId));
|
||||
}
|
||||
const stateId = params.get("stateId");
|
||||
if (stateId) {
|
||||
setStateId(parseInt(stateId));
|
||||
}
|
||||
const cityId = params.get("cityId");
|
||||
if (cityId) {
|
||||
setCityId(parseInt(cityId));
|
||||
}
|
||||
const categoryId = params.get("categoryId");
|
||||
if (categoryId) {
|
||||
setCategoryId(categoryId);
|
||||
console.log("cate", categoryId);
|
||||
form.setValue("categoryId", categoryId);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
// Initialize form
|
||||
const form = useForm<SearchFormValues>({
|
||||
resolver: zodResolver(searchFormSchema),
|
||||
defaultValues: {
|
||||
categoryId: "",
|
||||
cityId: 0,
|
||||
stateId: 0,
|
||||
countryId: 101,
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ["categoryTree"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/categories/tree");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Flatten category tree for rendering
|
||||
const flattenCategories = (categories: any[] = []) => {
|
||||
let result: any[] = [];
|
||||
categories.forEach((category) => {
|
||||
result.push(category);
|
||||
if (category.children && category.children.length > 0) {
|
||||
result = [...result, ...flattenCategories(category.children)];
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const flatCategories = flattenCategories(categories);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = (values: SearchFormValues) => {
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
if (values.categoryId) {
|
||||
params.append("categoryId", values.categoryId);
|
||||
}
|
||||
if (values.stateId) {
|
||||
params.append("stateId", values.stateId.toString());
|
||||
}
|
||||
if (values.cityId) {
|
||||
params.append("cityId", values.cityId.toString());
|
||||
}
|
||||
params.append(
|
||||
"countryId",
|
||||
values.countryId?.toString() || countryId.toString()
|
||||
);
|
||||
window.scrollTo(0, 0);
|
||||
// Redirect to search results page with query parameters
|
||||
router.push(`/search/results?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-5">
|
||||
<div className="w-full mx-auto">
|
||||
<div className="text-center mb-4">
|
||||
<p className="text-gray-500">Select Category & Location to Search</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-gray-200">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Single Row Layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
|
||||
{/* Category Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={categoryId ?? field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-10 w-full rounded text-sm">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{flatCategories?.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="countryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CountrySelect
|
||||
id="101"
|
||||
// @ts-ignore
|
||||
defaultValue={101}
|
||||
onChange={(countryObj: any) => {
|
||||
field.onChange(countryObj.id);
|
||||
setCountryId(countryObj.id);
|
||||
form.resetField("countryId");
|
||||
}}
|
||||
placeHolder="Select country"
|
||||
containerClassName="w-full"
|
||||
inputClassName="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={field.value || undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* State Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stateId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<StateSelect
|
||||
countryid={countryId}
|
||||
onChange={(stateObj: any) => {
|
||||
field.onChange(stateObj.id);
|
||||
form.resetField("cityId");
|
||||
}}
|
||||
placeHolder="Select state"
|
||||
containerClassName="w-full"
|
||||
inputClassName="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={field.value || undefined}
|
||||
// @ts-ignore
|
||||
defaultValue={stateId}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* City Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cityId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CitySelect
|
||||
countryid={countryId}
|
||||
stateid={form.getValues("stateId") || 0}
|
||||
onChange={(cityObj: any) => {
|
||||
field.onChange(cityObj.id);
|
||||
}}
|
||||
placeHolder="Select city"
|
||||
containerClassName="w-full"
|
||||
inputClassName="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={field.value || cityId}
|
||||
// @ts-ignore
|
||||
defaultValue={cityId}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Search Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="h- px-6 bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Search
|
||||
</Button>
|
||||
{params.size > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
// clear the params
|
||||
const params = new URLSearchParams();
|
||||
window.history.pushState({}, "", `/search`);
|
||||
form.reset();
|
||||
// page reload
|
||||
window.location.reload();
|
||||
}}
|
||||
className="h- px-6 bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Suspense fallback={null}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,286 @@
|
||||
export default function Page() {
|
||||
return <></>;
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
StateSelect,
|
||||
CitySelect,
|
||||
CountrySelect,
|
||||
} from "react-country-state-city";
|
||||
import "react-country-state-city/dist/react-country-state-city.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import api from "@/lib/api";
|
||||
|
||||
// Define the form schema
|
||||
const searchFormSchema = z.object({
|
||||
categoryId: z.string(),
|
||||
stateId: z.number().optional(),
|
||||
cityId: z.number().optional(),
|
||||
countryId: z.number().optional(),
|
||||
});
|
||||
|
||||
type SearchFormValues = z.infer<typeof searchFormSchema>;
|
||||
|
||||
export default function SearchPage() {
|
||||
const router = useRouter();
|
||||
const [countryId, setCountryId] = useState(101); // India
|
||||
const [stateId, setStateId] = useState(0);
|
||||
const [cityId, setCityId] = useState(0);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const params = useSearchParams();
|
||||
|
||||
// Initialize form
|
||||
const form = useForm<SearchFormValues>({
|
||||
resolver: zodResolver(searchFormSchema),
|
||||
defaultValues: {
|
||||
categoryId: "",
|
||||
cityId: 0,
|
||||
stateId: 0,
|
||||
countryId: 101,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const countryId = params.get("countryId");
|
||||
if (countryId) {
|
||||
setCountryId(parseInt(countryId));
|
||||
}
|
||||
const stateId = params.get("stateId");
|
||||
if (stateId) {
|
||||
setStateId(parseInt(stateId));
|
||||
}
|
||||
const cityId = params.get("cityId");
|
||||
if (cityId) {
|
||||
setCityId(parseInt(cityId));
|
||||
}
|
||||
const categoryId = params.get("categoryId");
|
||||
if (categoryId) {
|
||||
setCategoryId(categoryId);
|
||||
form.setValue("categoryId", categoryId);
|
||||
}
|
||||
}, [params, form]);
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ["categoryTree"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/categories/tree");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch search slogan
|
||||
const { data: sloganData } = useQuery({
|
||||
queryKey: ["searchSlogan"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/search-slogan");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Flatten category tree for rendering
|
||||
const flattenCategories = (categories: any[] = []) => {
|
||||
let result: any[] = [];
|
||||
categories.forEach((category) => {
|
||||
result.push(category);
|
||||
if (category.children && category.children.length > 0) {
|
||||
result = [...result, ...flattenCategories(category.children)];
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const flatCategories = flattenCategories(categories);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = (values: SearchFormValues) => {
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
if (values.categoryId) {
|
||||
params.append("categoryId", values.categoryId);
|
||||
}
|
||||
if (values.stateId) {
|
||||
params.append("stateId", values.stateId.toString());
|
||||
}
|
||||
if (values.cityId) {
|
||||
params.append("cityId", values.cityId.toString());
|
||||
}
|
||||
params.append(
|
||||
"countryId",
|
||||
values.countryId?.toString() || countryId.toString()
|
||||
);
|
||||
window.scrollTo(0, 0);
|
||||
// Redirect to search results page with query parameters
|
||||
router.push(`/search/results?${params.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-20 flex flex-col items-center justify-center px-4">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
{/* Main Slogan */}
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-900 mb-8">
|
||||
{sloganData?.primarySlogan || "Find What You Need"}
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-lg md:text-xl text-gray-600 mb-12">
|
||||
{sloganData?.secondarySlogan || "Search through thousands of classified advertisements"}
|
||||
</p>
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 max-w-5xl mx-auto">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Form Fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
|
||||
{/* Category Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={categoryId ?? field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="h-10 w-full rounded text-sm">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{flatCategories?.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="countryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CountrySelect
|
||||
id="101"
|
||||
// @ts-ignore
|
||||
defaultValue={101}
|
||||
onChange={(countryObj: any) => {
|
||||
field.onChange(countryObj.id);
|
||||
setCountryId(countryObj.id);
|
||||
form.resetField("countryId");
|
||||
}}
|
||||
placeHolder="Select country"
|
||||
containerClassName="w-full"
|
||||
inputClassName="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={field.value || undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* State Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stateId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<StateSelect
|
||||
countryid={countryId}
|
||||
onChange={(stateObj: any) => {
|
||||
field.onChange(stateObj.id);
|
||||
form.resetField("cityId");
|
||||
}}
|
||||
placeHolder="Select state"
|
||||
containerClassName="w-full"
|
||||
inputClassName="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={field.value || undefined}
|
||||
// @ts-ignore
|
||||
defaultValue={stateId}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* City Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cityId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CitySelect
|
||||
countryid={countryId}
|
||||
stateid={form.getValues("stateId") || 0}
|
||||
onChange={(cityObj: any) => {
|
||||
field.onChange(cityObj.id);
|
||||
}}
|
||||
placeHolder="Select city"
|
||||
containerClassName="w-full"
|
||||
inputClassName="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={field.value || cityId}
|
||||
// @ts-ignore
|
||||
defaultValue={cityId}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Search Button */}
|
||||
<Button type="submit" className="h-10 px-6">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
Search
|
||||
</Button>
|
||||
{params.size > 0 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
// clear the params
|
||||
const params = new URLSearchParams();
|
||||
window.history.pushState({}, "", `/search`);
|
||||
form.reset();
|
||||
// page reload
|
||||
window.location.reload();
|
||||
}}
|
||||
className="h-10 px-6"
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
368
src/app/(website)/search/results/line-ads-with-pagination.tsx
Normal file
368
src/app/(website)/search/results/line-ads-with-pagination.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import api from "@/lib/api";
|
||||
import { LineAd } from "@/lib/types/lineAd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import LineAdCard from "../../components/line-ad/line-ad-card";
|
||||
|
||||
// Image Gallery component
|
||||
const ImageGallery = ({
|
||||
images,
|
||||
apiUrl,
|
||||
}: {
|
||||
images: any[];
|
||||
apiUrl: string | undefined;
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showLightbox, setShowLightbox] = useState(false);
|
||||
|
||||
if (!images || images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative group">
|
||||
<div
|
||||
className="w-full overflow-hidden cursor-pointer"
|
||||
onClick={() => setShowLightbox(true)}
|
||||
>
|
||||
<img
|
||||
src={`/api/images/?imageName=${images[currentIndex].fileName}`}
|
||||
alt="Advertisement"
|
||||
className="object-cover w-full h-auto transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Image count indicator */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-full">
|
||||
{currentIndex + 1}/{images.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail navigation for multiple images */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-center gap-1 p-2 bg-gradient-to-t from-black/50 to-transparent">
|
||||
{images.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-all",
|
||||
currentIndex === idx
|
||||
? "bg-white scale-125"
|
||||
: "bg-white/50 hover:bg-white/80"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCurrentIndex(idx);
|
||||
}}
|
||||
aria-label={`View image ${idx + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox dialog */}
|
||||
<Dialog open={showLightbox} onOpenChange={setShowLightbox}>
|
||||
<DialogContent className="max-w-4xl p-0 bg-black border-none">
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 z-10 bg-black/50 text-white hover:bg-black/70 rounded-full"
|
||||
onClick={() => setShowLightbox(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<img
|
||||
src={`/api/images?imageName=${images[currentIndex].fileName}`}
|
||||
alt="Advertisement"
|
||||
className="max-h-[80vh] w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2">
|
||||
{images.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={cn(
|
||||
"w-3 h-3 rounded-full transition-all",
|
||||
currentIndex === idx
|
||||
? "bg-white scale-125"
|
||||
: "bg-white/50 hover:bg-white/80"
|
||||
)}
|
||||
onClick={() => setCurrentIndex(idx)}
|
||||
aria-label={`View image ${idx + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Error display component
|
||||
const ErrorDisplay = ({ onRetry }: { onRetry: () => void }) => (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Failed to load advertisements
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
There was an error loading the advertisements. Please try again.
|
||||
</p>
|
||||
<Button onClick={onRetry}>Retry</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function LineAdsWithPagination() {
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [stateId, setStateId] = useState("");
|
||||
const [cityId, setCityId] = useState("");
|
||||
const [countryId, setCountryId] = useState("");
|
||||
const [paramsReady, setParamsReady] = useState(false);
|
||||
const params = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const categoryId = params.get("categoryId");
|
||||
const stateId = params.get("stateId");
|
||||
const cityId = params.get("cityId");
|
||||
const countryId = params.get("countryId");
|
||||
|
||||
if (categoryId) setCategoryId(categoryId);
|
||||
if (stateId) setStateId(stateId);
|
||||
if (cityId) setCityId(cityId);
|
||||
if (countryId) setCountryId(countryId);
|
||||
setParamsReady(true);
|
||||
}, [params]);
|
||||
|
||||
// Fetch line ads with filters
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["search-linead", categoryId, stateId, cityId, countryId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId) params.append("categoryId", categoryId);
|
||||
if (stateId) params.append("stateId", stateId);
|
||||
if (cityId) params.append("cityId", cityId);
|
||||
if (countryId) params.append("countryId", countryId);
|
||||
|
||||
const { data } = await api.get(`/line-ad/today?${params.toString()}`);
|
||||
return data.sort((a: LineAd, b: LineAd) => {
|
||||
const dateA = new Date(a.updated_at || a.created_at).getTime();
|
||||
const dateB = new Date(b.updated_at || b.created_at).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
},
|
||||
enabled: paramsReady,
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const ADS_PER_PAGE = 12;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Calculate total pages
|
||||
const totalPages = data ? Math.ceil(data.length / ADS_PER_PAGE) : 1;
|
||||
|
||||
// Get paginated items
|
||||
const getPaginatedItems = () => {
|
||||
if (!data) return [];
|
||||
const startIdx = (currentPage - 1) * ADS_PER_PAGE;
|
||||
return data.slice(startIdx, startIdx + ADS_PER_PAGE);
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// Reset to first page when search params change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [categoryId, stateId, cityId, countryId]);
|
||||
|
||||
// Scroll to top when currentPage changes
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, [currentPage]);
|
||||
|
||||
// Generate pagination numbers with ellipsis
|
||||
const getPaginationNumbers = () => {
|
||||
const delta = 2; // How many pages to show around current page
|
||||
const numbers = [];
|
||||
const maxPages = Math.min(totalPages, 7); // Maximum buttons to show
|
||||
|
||||
if (totalPages <= 7) {
|
||||
// Show all pages
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
numbers.push(i);
|
||||
}
|
||||
} else {
|
||||
// Show first page
|
||||
numbers.push(1);
|
||||
|
||||
// Show ellipsis if needed
|
||||
if (currentPage > delta + 2) {
|
||||
numbers.push("...");
|
||||
}
|
||||
|
||||
// Show pages around current page
|
||||
const start = Math.max(2, currentPage - delta);
|
||||
const end = Math.min(totalPages - 1, currentPage + delta);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
numbers.push(i);
|
||||
}
|
||||
|
||||
// Show ellipsis if needed
|
||||
if (currentPage < totalPages - delta - 1) {
|
||||
numbers.push("...");
|
||||
}
|
||||
|
||||
// Show last page
|
||||
if (totalPages > 1) {
|
||||
numbers.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return numbers;
|
||||
};
|
||||
|
||||
if (isError) {
|
||||
return <ErrorDisplay onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="py-4">
|
||||
{/* Results Summary */}
|
||||
{data && (
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
{data.length > 0 ? (
|
||||
<p>
|
||||
Showing {getPaginatedItems().length} of {data.length} results
|
||||
{totalPages > 1 && ` (Page ${currentPage} of ${totalPages})`}
|
||||
</p>
|
||||
) : (
|
||||
<p>No results found</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="columns-1 sm:columns-2 md:columns-3 lg:columns-4 gap-4 space-y-0">
|
||||
{Array(8)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="h-full flex flex-col overflow-hidden break-inside-avoid mb-4"
|
||||
>
|
||||
<div className="p-4 flex-grow">
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
<Skeleton className="h-5 w-20" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-3/4 mb-2" />
|
||||
<Skeleton className="h-3 w-32 mt-4 mb-1" />
|
||||
<Skeleton className="h-3 w-40 mb-1" />
|
||||
<Skeleton className="h-3 w-24 mb-3" />
|
||||
</div>
|
||||
<div className="h-48 w-full">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : data && data.length > 0 ? (
|
||||
<>
|
||||
<div className="columns-1 sm:columns-2 md:columns-2 lg:columns-3 xl:columns-4 gap-4 space-y-8 transition-all ease-in">
|
||||
{getPaginatedItems().map((ad: any, index: number) => (
|
||||
<LineAdCard key={ad.id} ad={ad} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-4 mt-8">
|
||||
{/* Previous Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
<div className="flex items-center gap-1">
|
||||
{getPaginationNumbers().map((pageNum, index) => (
|
||||
<div key={index}>
|
||||
{pageNum === "..." ? (
|
||||
<span className="px-2 py-1 text-gray-500">...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={pageNum === currentPage ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(pageNum as number)}
|
||||
className="min-w-[32px]"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="max-w-md mx-auto">
|
||||
<AlertCircle className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No advertisements found
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Try adjusting your search filters or check back later for new listings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,226 @@
|
||||
"use client";
|
||||
import api from "@/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import QuickSearchBar from "./quick-search";
|
||||
|
||||
// Define the form schema
|
||||
const searchFormSchema = z.object({
|
||||
categoryId: z.string(),
|
||||
stateId: z.number(),
|
||||
cityId: z.number(),
|
||||
});
|
||||
type SearchFormValues = z.infer<typeof searchFormSchema>;
|
||||
export default function Page() {
|
||||
import api from "@/lib/api";
|
||||
import { PosterAd } from "@/lib/types/posterAd";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import PosterVideoAdSides from "../../components/poster-video-ad-sides";
|
||||
import LineAdsWithPagination from "./line-ads-with-pagination";
|
||||
import QuickSearchBar from "./quick-search";
|
||||
import { ChevronLeftCircle, ChevronRightCircle } from "lucide-react";
|
||||
|
||||
export default function SearchResultsPage() {
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [stateId, setStateId] = useState("");
|
||||
const [cityId, setCityId] = useState("");
|
||||
const [countryId, setCountryId] = useState("");
|
||||
const [paramsReady, setParamsReady] = useState(false);
|
||||
const params = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
const categoryId = params.get("categoryId");
|
||||
const stateId = params.get("stateId");
|
||||
const cityId = params.get("cityId");
|
||||
const countryId = params.get("countryId");
|
||||
|
||||
if (categoryId) setCategoryId(categoryId);
|
||||
if (stateId) setStateId(stateId);
|
||||
if (cityId) setCityId(cityId);
|
||||
if (countryId) setCountryId(countryId);
|
||||
setParamsReady(true);
|
||||
}, [params]);
|
||||
|
||||
const data = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ["search-line-ads", categoryId, stateId, cityId, countryId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId) params.append("categoryId", categoryId);
|
||||
if (stateId) params.append("stateId", stateId);
|
||||
if (cityId) params.append("cityId", cityId);
|
||||
if (countryId) params.append("countryId", countryId);
|
||||
|
||||
const response = await api.get(`line-ad/today?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: paramsReady,
|
||||
},
|
||||
{
|
||||
queryKey: ["search-video-ads", categoryId, stateId, cityId, countryId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId) params.append("categoryId", categoryId);
|
||||
if (stateId) params.append("stateId", stateId);
|
||||
if (cityId) params.append("cityId", cityId);
|
||||
if (countryId) params.append("countryId", countryId);
|
||||
|
||||
const response = await api.get(`video-ad/today?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: paramsReady,
|
||||
},
|
||||
{
|
||||
queryKey: ["search-poster-ads", categoryId, stateId, cityId, countryId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (categoryId) params.append("categoryId", categoryId);
|
||||
if (stateId) params.append("stateId", stateId);
|
||||
if (cityId) params.append("cityId", cityId);
|
||||
if (countryId) params.append("countryId", countryId);
|
||||
|
||||
const response = await api.get(`poster-ad/today?${params.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: paramsReady,
|
||||
},
|
||||
],
|
||||
combine(result) {
|
||||
const [lineAds, videoAds, posterAds] = result;
|
||||
|
||||
const filteredPosterAds = posterAds.data?.filter((ad: PosterAd) => {
|
||||
return (
|
||||
ad.position?.side !== "CENTER_BOTTOM" &&
|
||||
ad.position?.side !== "CENTER_TOP"
|
||||
);
|
||||
});
|
||||
return {
|
||||
lineAds: lineAds.data || [],
|
||||
filteredPosterAds: filteredPosterAds || [],
|
||||
videoAds: videoAds.data || [],
|
||||
centerBottomPosterAd:
|
||||
posterAds.data?.find(
|
||||
(ad: PosterAd) => ad.position?.side === "CENTER_BOTTOM"
|
||||
) || null,
|
||||
centerTopPosterAd:
|
||||
posterAds.data?.find(
|
||||
(ad: PosterAd) => ad.position?.side === "CENTER_TOP"
|
||||
) || null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Custom center component with search bar
|
||||
const CenterContentWithSearch = () => {
|
||||
const tads = data.centerTopPosterAd ? [data.centerTopPosterAd] : [];
|
||||
const bads = data.centerBottomPosterAd ? [data.centerBottomPosterAd] : [];
|
||||
const totalTopAds = tads.length;
|
||||
const totalBottomAds = bads.length;
|
||||
const [currentTopAdIndex, setCurrentTopAdIndex] = useState(0);
|
||||
const [currentBottomAdIndex, setCurrentBottomAdIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="col-span-8 flex flex-col gap-5">
|
||||
{/* Top Ads */}
|
||||
{tads && tads.length > 0 && (
|
||||
<div className="aspect-video overflow-hidden h-72 relative select-none">
|
||||
{currentTopAdIndex > 0 && (
|
||||
<ChevronLeftCircle
|
||||
className="cursor-pointer size-8 text-gray-800 absolute top-1/2 left-2 -translate-y-1/2 bg-white shadow-xl rounded-full z-10"
|
||||
onClick={() => {
|
||||
setCurrentTopAdIndex(
|
||||
(currentTopAdIndex - 1 + totalTopAds) % totalTopAds
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentTopAdIndex < totalTopAds - 1 && (
|
||||
<ChevronRightCircle
|
||||
onClick={() => {
|
||||
setCurrentTopAdIndex((currentTopAdIndex + 1) % totalTopAds);
|
||||
}}
|
||||
className="cursor-pointer size-8 text-gray-800 absolute top-1/2 right-2 -translate-y-1/2 bg-white shadow-xl rounded-full z-10"
|
||||
/>
|
||||
)}
|
||||
{totalTopAds > 1 && (
|
||||
<div className="pb-0.5 absolute bg-black px-3 bottom-2 rounded-full left-1/2 -translate-x-1/2 z-10">
|
||||
<span className="leading-none text-white text-xs">
|
||||
{currentTopAdIndex + 1} / {totalTopAds}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={`/api/images?imageName=${tads[currentTopAdIndex].image.fileName}`}
|
||||
alt={tads[currentTopAdIndex].mainCategory.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Small Search Bar */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-3">
|
||||
<QuickSearchBar />
|
||||
</div>
|
||||
|
||||
{/* Line Ads */}
|
||||
<LineAdsWithPagination />
|
||||
|
||||
{/* Bottom Ads */}
|
||||
{bads && bads.length > 0 && (
|
||||
<div className="aspect-video overflow-hidden h-72 relative select-none">
|
||||
{currentBottomAdIndex > 0 && (
|
||||
<ChevronLeftCircle
|
||||
className="cursor-pointer size-8 text-gray-800 absolute top-1/2 left-2 -translate-y-1/2 bg-white shadow-xl rounded-full z-10"
|
||||
onClick={() => {
|
||||
setCurrentBottomAdIndex(
|
||||
(currentBottomAdIndex - 1 + totalBottomAds) % totalBottomAds
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentBottomAdIndex < totalBottomAds - 1 && (
|
||||
<ChevronRightCircle
|
||||
onClick={() => {
|
||||
setCurrentBottomAdIndex(
|
||||
(currentBottomAdIndex + 1) % totalBottomAds
|
||||
);
|
||||
}}
|
||||
className="cursor-pointer size-8 text-gray-800 absolute top-1/2 right-2 -translate-y-1/2 bg-white shadow-xl rounded-full z-10"
|
||||
/>
|
||||
)}
|
||||
{totalBottomAds > 1 && (
|
||||
<div className="pb-0.5 absolute bg-black px-3 bottom-2 rounded-full left-1/2 -translate-x-1/2 z-10">
|
||||
<span className="leading-none text-white text-xs">
|
||||
{currentBottomAdIndex + 1} / {totalBottomAds}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4">
|
||||
<img
|
||||
src={`/api/images?imageName=${bads[currentBottomAdIndex].image.fileName}`}
|
||||
alt={bads[currentBottomAdIndex].mainCategory.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<QuickSearchBar />
|
||||
</Suspense>
|
||||
</>
|
||||
<div className="pt-5 px-10 grid grid-cols-12 gap-5">
|
||||
<div className="col-span-2">
|
||||
<PosterVideoAdSides
|
||||
side="left"
|
||||
posterAds={data.filteredPosterAds}
|
||||
videoAds={data.videoAds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center Content with Search Bar */}
|
||||
<CenterContentWithSearch />
|
||||
|
||||
<div className="col-span-2">
|
||||
<PosterVideoAdSides
|
||||
side="right"
|
||||
posterAds={data.filteredPosterAds}
|
||||
videoAds={data.videoAds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,13 +30,32 @@ export default function QuickSearchBar({
|
||||
const currentCityId = searchParams.get("cityId") || "";
|
||||
|
||||
useEffect(() => {
|
||||
setCountryId(101);
|
||||
setCountryName("India");
|
||||
setStateId(0);
|
||||
setStateName("");
|
||||
setCityId(0);
|
||||
setCityName("");
|
||||
setCategoryId("");
|
||||
// Set values from URL parameters
|
||||
const urlCategoryId = searchParams.get("categoryId") || "";
|
||||
const urlStateId = searchParams.get("stateId");
|
||||
const urlCityId = searchParams.get("cityId");
|
||||
const urlCountryId = searchParams.get("countryId");
|
||||
|
||||
setCategoryId(urlCategoryId);
|
||||
setStateId(urlStateId ? parseInt(urlStateId) : 0);
|
||||
setCityId(urlCityId ? parseInt(urlCityId) : 0);
|
||||
|
||||
// Set country from URL or default to India
|
||||
if (urlCountryId) {
|
||||
setCountryId(parseInt(urlCountryId));
|
||||
// Don't set country name here, let the CountrySelect component handle it
|
||||
} else {
|
||||
setCountryId(101);
|
||||
setCountryName("India");
|
||||
}
|
||||
|
||||
// Reset state/city names if not in URL (they'll be set by the select components)
|
||||
if (!urlStateId) {
|
||||
setStateName("");
|
||||
}
|
||||
if (!urlCityId) {
|
||||
setCityName("");
|
||||
}
|
||||
}, [searchParams]);
|
||||
// Quick search form state
|
||||
const [countryId, setCountryId] = useState<number>(101); // India as default
|
||||
@@ -84,11 +103,15 @@ export default function QuickSearchBar({
|
||||
params.append("categoryId", categoryId);
|
||||
}
|
||||
|
||||
if (stateName) {
|
||||
if (countryId) {
|
||||
params.append("countryId", countryId.toString());
|
||||
}
|
||||
|
||||
if (stateId && stateId > 0) {
|
||||
params.append("stateId", stateId.toString());
|
||||
}
|
||||
|
||||
if (cityName) {
|
||||
if (cityId && cityId > 0) {
|
||||
params.append("cityId", cityId.toString());
|
||||
}
|
||||
|
||||
@@ -117,6 +140,7 @@ export default function QuickSearchBar({
|
||||
Country
|
||||
</label>
|
||||
<CountrySelect
|
||||
key={`country-${countryId}`}
|
||||
onChange={(e: any) => {
|
||||
setCountryId(e.id);
|
||||
setCountryName(e.name);
|
||||
@@ -128,7 +152,8 @@ export default function QuickSearchBar({
|
||||
placeHolder="Select country"
|
||||
containerClassName="w-full"
|
||||
inputClassName="w-full h-9 text-sm rounded-md border border-gray-300 px-3 py-1 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
value={countryId || undefined}
|
||||
// @ts-ignore
|
||||
defaultValue={countryId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -138,6 +163,7 @@ export default function QuickSearchBar({
|
||||
State
|
||||
</label>
|
||||
<StateSelect
|
||||
key={`state-${countryId}-${stateId}`}
|
||||
countryid={countryId}
|
||||
onChange={(e: any) => {
|
||||
setStateId(e.id);
|
||||
@@ -145,7 +171,8 @@ export default function QuickSearchBar({
|
||||
setCityId(0);
|
||||
setCityName("");
|
||||
}}
|
||||
value={stateId || undefined}
|
||||
// @ts-ignore
|
||||
defaultValue={stateId}
|
||||
placeHolder="Select state"
|
||||
containerClassName="w-full"
|
||||
inputClassName="w-full h-9 text-sm rounded-md border border-gray-300 px-3 py-1 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
@@ -158,9 +185,11 @@ export default function QuickSearchBar({
|
||||
City
|
||||
</label>
|
||||
<CitySelect
|
||||
key={`city-${countryId}-${stateId}-${cityId}`}
|
||||
countryid={countryId}
|
||||
stateid={stateId || 0}
|
||||
value={cityId || undefined}
|
||||
// @ts-ignore
|
||||
defaultValue={cityId}
|
||||
onChange={(e: any) => {
|
||||
setCityId(e.id);
|
||||
setCityName(e.name);
|
||||
@@ -182,6 +211,7 @@ export default function QuickSearchBar({
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
className="w-full h-9 text-sm rounded-md border border-gray-300 px-3 py-1 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{flatCategories?.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
@@ -222,8 +252,8 @@ export default function QuickSearchBar({
|
||||
</div>
|
||||
|
||||
{countryName && countryName !== "India" && (
|
||||
<span className="bg-purple-100 text-purple-700 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
🌍 {countryName}
|
||||
<span className="bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
{countryName}
|
||||
<button
|
||||
onClick={() => {
|
||||
setCountryId(101);
|
||||
@@ -233,7 +263,7 @@ export default function QuickSearchBar({
|
||||
setCityId(0);
|
||||
setCityName("");
|
||||
}}
|
||||
className="hover:text-purple-900"
|
||||
className="hover:text-gray-900"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
@@ -241,7 +271,7 @@ export default function QuickSearchBar({
|
||||
)}
|
||||
|
||||
{stateName && (
|
||||
<span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
<span className="bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
<MapPin size={10} /> {stateName}
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -250,7 +280,7 @@ export default function QuickSearchBar({
|
||||
setCityId(0);
|
||||
setCityName("");
|
||||
}}
|
||||
className="hover:text-green-900"
|
||||
className="hover:text-gray-900"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
@@ -258,14 +288,14 @@ export default function QuickSearchBar({
|
||||
)}
|
||||
|
||||
{cityName && (
|
||||
<span className="bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
🏙️ {cityName}
|
||||
<span className="bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
{cityName}
|
||||
<button
|
||||
onClick={() => {
|
||||
setCityId(0);
|
||||
setCityName("");
|
||||
}}
|
||||
className="hover:text-blue-900"
|
||||
className="hover:text-gray-900"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
@@ -273,11 +303,11 @@ export default function QuickSearchBar({
|
||||
)}
|
||||
|
||||
{categoryId && flatCategories?.find((c) => c.id === categoryId) && (
|
||||
<span className="bg-orange-100 text-orange-700 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
📂 {flatCategories.find((c) => c.id === categoryId)?.name}
|
||||
<span className="bg-gray-100 text-gray-700 text-xs px-2 py-1 rounded-full flex items-center gap-1">
|
||||
{flatCategories.find((c) => c.id === categoryId)?.name}
|
||||
<button
|
||||
onClick={() => setCategoryId("")}
|
||||
className="hover:text-orange-900"
|
||||
className="hover:text-gray-900"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
|
||||
146
src/app/(website)/terms-and-conditions/page.tsx
Normal file
146
src/app/(website)/terms-and-conditions/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ScrollText, Calendar, User } from "lucide-react";
|
||||
import api from "@/lib/api";
|
||||
|
||||
export default function TermsAndConditionsPage() {
|
||||
const { data: termsData, isLoading, error } = useQuery({
|
||||
queryKey: ["termsAndConditions"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/terms-and-conditions");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !termsData) {
|
||||
return (
|
||||
<div className="pt-20 px-4">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">Terms and Conditions</h1>
|
||||
<p className="text-gray-600">Terms and conditions not available at the moment.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-20 px-4 pb-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<ScrollText className="h-8 w-8 text-blue-600" />
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900">
|
||||
Terms and Conditions
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||
Please read these terms and conditions carefully before using our service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
{termsData.version && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="h-4 w-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Version</p>
|
||||
<p className="font-semibold">{termsData.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{termsData.effectiveDate && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Effective Date</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(termsData.effectiveDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{termsData.lastUpdated && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Updated</p>
|
||||
<p className="font-semibold">
|
||||
{new Date(termsData.lastUpdated).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms Content */}
|
||||
<Card className="mb-8">
|
||||
<CardContent className="pt-6">
|
||||
{termsData.content ? (
|
||||
<div
|
||||
className="prose prose-lg max-w-none text-gray-700 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: termsData.content }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<ScrollText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Terms and conditions content is being updated.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer Information */}
|
||||
<Card className="bg-gray-50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
By using our service, you agree to these terms and conditions.
|
||||
</p>
|
||||
{termsData.updatedBy && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-gray-500">
|
||||
<User className="h-3 w-3" />
|
||||
<span>Last updated by: {termsData.updatedBy}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,10 +11,8 @@ export async function GET(req: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const imageUrl =
|
||||
process.env.NODE_ENV === "development"
|
||||
? `http://localhost:3001/${basePath}/${imageName}`
|
||||
: `https://paisaads-v20-backend.anujs.dev/server/${basePath}/${imageName}`;
|
||||
// const imageUrl =`http://localhost:3001/${basePath}/${imageName}`;
|
||||
const imageUrl =`https://paisaads.in/server/${basePath}/${imageName}`;
|
||||
|
||||
const response = await fetch(imageUrl);
|
||||
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client";
|
||||
import { EditAdForm } from "@/components/forms/edit-line-ad-form";
|
||||
import type { Metadata } from "next";
|
||||
import EditLineAdForm from "@/components/forms/edit-line-ad-form";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function EditAdPage() {
|
||||
const params = useParams();
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div className="space-y-6 mx-auto">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">Edit Advertisement</h1>
|
||||
</div>
|
||||
|
||||
<EditAdForm adId={params.id as string} />
|
||||
<EditLineAdForm adId={params.id as string} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
import { EditAdForm } from "@/components/forms/edit-line-ad-form";
|
||||
import { EditPosterAd } from "@/components/forms/edit-poster-ad-form";
|
||||
import EditPosterAd from "@/app/mgmt/dashboard/review-ads/poster/edit/[id]/page";
|
||||
import EditPosterAdForm from "@/components/forms/edit-poster-ad-form";
|
||||
import type { Metadata } from "next";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function EditAdPage() {
|
||||
const params = useParams();
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto">
|
||||
<div className="">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">Edit Line Ad</h1>
|
||||
</div>
|
||||
|
||||
<EditPosterAd adId={params.id as string} />
|
||||
<EditPosterAdForm adId={params.id as string} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
import { EditAdForm } from "@/components/forms/edit-line-ad-form";
|
||||
import { EditPosterAd } from "@/components/forms/edit-poster-ad-form";
|
||||
import { EditVideoAd } from "@/components/forms/edit-video-ad-form";
|
||||
import { EditVideoAdForm } from "@/components/forms/edit-video-ad-form";
|
||||
import type { Metadata } from "next";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
@@ -13,7 +11,7 @@ export default function EditAdPage() {
|
||||
<h1 className="text-2xl font-bold">Edit Video Ad</h1>
|
||||
</div>
|
||||
|
||||
<EditVideoAd adId={params.id as string} />
|
||||
<EditVideoAdForm adId={params.id as string} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
import Zoom from 'react-medium-image-zoom'
|
||||
export const columns: ColumnDef<LineAd>[] = [
|
||||
{
|
||||
accessorKey: "sequenceNumber",
|
||||
@@ -76,6 +76,7 @@ export const columns: ColumnDef<LineAd>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: ({ column }) => {
|
||||
@@ -91,13 +92,28 @@ export const columns: ColumnDef<LineAd>[] = [
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="space-y-1">
|
||||
<div>{truncateContent(row.getValue("content"))}</div>
|
||||
<div>{truncateContent(row.getValue("content"),20)}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.original.city}, {row.original.state}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey:'images',
|
||||
header:()=>{
|
||||
return "Images"
|
||||
},
|
||||
cell:({row}) =>{
|
||||
{console.log(row.original.images)}
|
||||
return <div className="flex">
|
||||
|
||||
{row.original.images.map((image) => <Zoom key={image.id}>
|
||||
<img className="size-12 aspect-square" src={`/api/images?imageName=${image.fileName}`}></img>
|
||||
</Zoom>)}
|
||||
</div>
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "dates",
|
||||
header: ({ column }) => {
|
||||
@@ -248,18 +264,18 @@ export const columns: ColumnDef<LineAd>[] = [
|
||||
return paymentA - paymentB;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "isActive",
|
||||
header: "Active",
|
||||
cell: ({ row }) => {
|
||||
const isActive = row.original.isActive;
|
||||
return isActive ? (
|
||||
<Check className="h-4 w-4 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<X className="h-4 w-4 text-red-600 mx-auto" />
|
||||
);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// accessorKey: "isActive",
|
||||
// header: "Active",
|
||||
// cell: ({ row }) => {
|
||||
// const isActive = row.original.isActive;
|
||||
// return isActive ? (
|
||||
// <Check className="h-4 w-4 text-green-600 mx-auto" />
|
||||
// ) : (
|
||||
// <X className="h-4 w-4 text-red-600 mx-auto" />
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function MyAdsDataTable() {
|
||||
<h3 className="text-lg font-semibold mb-2">No advertisements yet</h3>
|
||||
<p className="text-muted-foreground mb-4">Create your first advertisement to get started.</p>
|
||||
<Button asChild>
|
||||
<Link href="/dashboard/post-ad">Create Advertisement</Link>
|
||||
<Link href="/dashboard/post-ad/line-ad">Create Advertisement</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const columns: ColumnDef<VideoAd>[] = [
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <div className="">{row.original.position.replace("_", " ")}</div>;
|
||||
return <div className="">{row.original.position.pageType}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,16 +19,16 @@ export default function CustomerProfilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
// const { data: customer, isLoading: isCustomerLoading } = useQuery<Customer>({
|
||||
// queryKey: ["customer"],
|
||||
// queryFn: async () => {
|
||||
// const { data } = await api.get("/users/customer/me");
|
||||
// return data;
|
||||
// },
|
||||
// enabled: !!user,
|
||||
// });
|
||||
const { data: customer, isLoading: isCustomerLoading } = useQuery<Customer>({
|
||||
queryKey: ["customer"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/users/customer/me");
|
||||
return data;
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const isLoading = isUserLoading;
|
||||
const isLoading = isUserLoading || isCustomerLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -41,7 +41,6 @@ export default function CustomerProfilePage() {
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -64,33 +63,11 @@ export default function CustomerProfilePage() {
|
||||
<div className="space-y-6 max-w-4xl mx-auto p-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">My Profile</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your account settings and preferences.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<Tabs defaultValue="account" className="w-full">
|
||||
<TabsList className="grid w-full md:w-auto grid-cols-2 md:grid-cols-3">
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
{/* <TabsTrigger value="location">Location</TabsTrigger> */}
|
||||
<TabsTrigger value="security">Security</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account" className="mt-6">
|
||||
<BasicInfoForm user={user} />
|
||||
</TabsContent>
|
||||
{/* <TabsContent value="location" className="mt-6">
|
||||
{customer && (
|
||||
<CustomerLocationForm
|
||||
customer={customer}
|
||||
customerId={customer.id}
|
||||
/>
|
||||
)}
|
||||
</TabsContent> */}
|
||||
<TabsContent value="security" className="mt-6">
|
||||
<ChangePasswordForm />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="space-y-8">
|
||||
<BasicInfoForm user={user} customer={customer} />
|
||||
<ChangePasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { ViewAdDetails } from "@/components/forms/view-line-ad-form";
|
||||
import EditLineAdForm from "@/components/forms/edit-line-ad-form";
|
||||
import { ViewAdForm } from "@/components/forms/view-ad-form";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
export default function ViewAdPage() {
|
||||
const params: { id: string } = useParams();
|
||||
return <ViewAdDetails adId={params.id} />;
|
||||
return <ViewAdForm adId={params.id} adType="line" />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { ViewPosterAdForm } from "@/components/forms/view-poster-ad-form";
|
||||
import { ViewAdForm } from "@/components/forms/view-ad-form";
|
||||
|
||||
export default function ViewAdPage() {
|
||||
const params: { id: string } = useParams();
|
||||
return <ViewPosterAdForm adId={params.id} />;
|
||||
return <ViewAdForm adId={params.id} adType="poster" />;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { ViewPosterAdForm } from "@/components/forms/view-poster-ad-form";
|
||||
import { ViewVideoAdForm } from "@/components/forms/view-video-ad-form";
|
||||
import { ViewAdForm } from "@/components/forms/view-ad-form";
|
||||
|
||||
export default function ViewAdPage() {
|
||||
const params: { id: string } = useParams();
|
||||
return <ViewVideoAdForm adId={params.id} />;
|
||||
return <ViewAdForm adId={params.id} adType="video" />;
|
||||
}
|
||||
|
||||
@@ -137,3 +137,172 @@
|
||||
.stsearch-box input {
|
||||
@apply file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-blink {
|
||||
animation: blink 1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse-soft {
|
||||
animation: pulseSoft 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.transition-all-smooth {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.transition-colors-smooth {
|
||||
transition: color 0.2s ease, background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.transition-transform-smooth {
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulseSoft {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skeleton shimmer gradient */
|
||||
.skeleton-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Dark mode shimmer */
|
||||
.dark .skeleton-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Smooth state transitions */
|
||||
.state-enter {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.state-enter-active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 300ms ease-out, transform 300ms ease-out;
|
||||
}
|
||||
|
||||
.state-exit {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.state-exit-active {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 200ms ease-in, transform 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Loading overlay */
|
||||
.loading-overlay {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(2px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dark .loading-overlay {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Progressive reveal */
|
||||
.progressive-reveal {
|
||||
animation: progressiveReveal 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes progressiveReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
60% {
|
||||
opacity: 0.8;
|
||||
transform: translateY(5px) scale(0.99);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
756
src/app/mgmt/dashboard/ad-slots-overview/page.tsx
Normal file
756
src/app/mgmt/dashboard/ad-slots-overview/page.tsx
Normal file
@@ -0,0 +1,756 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Grid,
|
||||
Filter,
|
||||
Layout,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import AdSlotsApi from "@/lib/services/ad-slots-api";
|
||||
import {
|
||||
DateBasedAdSlotsOverview,
|
||||
DateBasedLineAds,
|
||||
DateBasedSlotDetails,
|
||||
DateBasedSlotOccupancy,
|
||||
DateBasedLineAd,
|
||||
DateBasedSlotAdDetail,
|
||||
PageTypeFilter,
|
||||
Category,
|
||||
} from "@/lib/types/ad-slots";
|
||||
|
||||
export default function AdSlotsOverviewPage() {
|
||||
const [pageTypeFilter, setPageTypeFilter] = useState<PageTypeFilter>("HOME");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("");
|
||||
const [selectedDate, setSelectedDate] = useState<string>(() => {
|
||||
// Initialize with today's date
|
||||
const today = new Date();
|
||||
return today.toISOString().split("T")[0];
|
||||
});
|
||||
const [selectedSlot, setSelectedSlot] =
|
||||
useState<DateBasedSlotOccupancy | null>(null);
|
||||
const [slotDetailsOpen, setSlotDetailsOpen] = useState(false);
|
||||
|
||||
// Fetch available dates
|
||||
const { data: availableDates } = useQuery({
|
||||
queryKey: ["availableDates"],
|
||||
queryFn: async () => {
|
||||
const response = await AdSlotsApi.getAvailableDates();
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
// Set selected date to the latest available date when dates are loaded
|
||||
useEffect(() => {
|
||||
if (
|
||||
availableDates &&
|
||||
availableDates.dates.length > 0 &&
|
||||
!availableDates.dates.includes(selectedDate)
|
||||
) {
|
||||
// Set to the latest date
|
||||
setSelectedDate(availableDates.dates[availableDates.dates.length - 1]);
|
||||
}
|
||||
}, [availableDates, selectedDate]);
|
||||
|
||||
// Fetch ad slots overview for selected date
|
||||
const {
|
||||
data: slotsData,
|
||||
isLoading: slotsLoading,
|
||||
error: slotsError,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"adSlotsByDate",
|
||||
selectedDate,
|
||||
pageTypeFilter !== "ALL" ? pageTypeFilter : undefined,
|
||||
selectedCategory || undefined,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const response = await AdSlotsApi.getAdSlotsByDate(
|
||||
selectedDate,
|
||||
pageTypeFilter !== "ALL" ? pageTypeFilter : undefined,
|
||||
selectedCategory || undefined
|
||||
);
|
||||
return response;
|
||||
},
|
||||
enabled: !!selectedDate,
|
||||
});
|
||||
|
||||
// Fetch line ads for selected date
|
||||
const { data: lineAdsData, isLoading: lineAdsLoading } = useQuery({
|
||||
queryKey: [
|
||||
"lineAdsByDate",
|
||||
selectedDate,
|
||||
pageTypeFilter !== "ALL" ? pageTypeFilter : undefined,
|
||||
selectedCategory || undefined,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const response = await AdSlotsApi.getLineAdsByDate(
|
||||
selectedDate,
|
||||
pageTypeFilter !== "ALL" ? pageTypeFilter : undefined,
|
||||
selectedCategory || undefined
|
||||
);
|
||||
return response;
|
||||
},
|
||||
enabled: !!selectedDate,
|
||||
});
|
||||
|
||||
// Fetch slot details when a slot is selected
|
||||
const { data: slotDetails, isLoading: slotDetailsLoading } = useQuery({
|
||||
queryKey: [
|
||||
"slotDetailsByDate",
|
||||
selectedDate,
|
||||
selectedSlot?.pageType,
|
||||
selectedSlot?.side,
|
||||
selectedSlot?.position,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!selectedSlot) return null;
|
||||
|
||||
const response = await AdSlotsApi.getSlotDetailsByDate(
|
||||
selectedDate,
|
||||
selectedSlot.pageType,
|
||||
selectedSlot.side,
|
||||
selectedSlot.position,
|
||||
selectedCategory || undefined
|
||||
);
|
||||
return response;
|
||||
},
|
||||
enabled: !!selectedSlot && slotDetailsOpen && !!selectedDate,
|
||||
});
|
||||
|
||||
// Get unique categories from the response
|
||||
const availableCategories = useMemo(() => {
|
||||
if (!slotsData) return [];
|
||||
return slotsData.categories;
|
||||
}, [slotsData]);
|
||||
|
||||
const handleSlotClick = (slot: DateBasedSlotOccupancy) => {
|
||||
setSelectedSlot(slot);
|
||||
setSlotDetailsOpen(true);
|
||||
};
|
||||
|
||||
const getSlotStatusColor = (slot: DateBasedSlotOccupancy) => {
|
||||
if (!slot.isOccupied) {
|
||||
return "bg-green-50 border-green-200 text-green-700 hover:bg-green-100";
|
||||
}
|
||||
|
||||
if (slot.activeAdsCount >= slot.maxCapacity) {
|
||||
return "bg-red-50 border-red-200 text-red-700 hover:bg-red-100";
|
||||
}
|
||||
|
||||
return "bg-yellow-50 border-yellow-200 text-yellow-700 hover:bg-yellow-100";
|
||||
};
|
||||
|
||||
const getSlotStatus = (
|
||||
slot: DateBasedSlotOccupancy
|
||||
): "FREE" | "PARTIALLY_OCCUPIED" | "FULL" => {
|
||||
if (!slot.isOccupied) return "FREE";
|
||||
if (slot.activeAdsCount >= slot.maxCapacity) return "FULL";
|
||||
return "PARTIALLY_OCCUPIED";
|
||||
};
|
||||
|
||||
const getStatusBadgeColor = (status: string | undefined) => {
|
||||
if (!status) return "bg-gray-100 text-gray-800";
|
||||
|
||||
switch (status) {
|
||||
case "PUBLISHED":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "SCHEDULED":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "EXPIRED":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryBadgeColor = (category: Category | undefined) => {
|
||||
if (!category) return "bg-gray-100 text-gray-800";
|
||||
if (category.color) {
|
||||
// Use the category color from database
|
||||
return `bg-[${category.color}]/10 text-[${category.color}]`;
|
||||
}
|
||||
return "bg-blue-100 text-blue-800";
|
||||
};
|
||||
|
||||
// Navigate to previous date
|
||||
const handlePreviousDate = () => {
|
||||
if (!availableDates || availableDates.dates.length === 0) return;
|
||||
const currentIndex = availableDates.dates.indexOf(selectedDate);
|
||||
if (currentIndex > 0) {
|
||||
setSelectedDate(availableDates.dates[currentIndex - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// Navigate to next date
|
||||
const handleNextDate = () => {
|
||||
if (!availableDates || availableDates.dates.length === 0) return;
|
||||
const currentIndex = availableDates.dates.indexOf(selectedDate);
|
||||
if (currentIndex < availableDates.dates.length - 1) {
|
||||
setSelectedDate(availableDates.dates[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset category filter when page type changes away from CATEGORY
|
||||
useEffect(() => {
|
||||
if (pageTypeFilter !== "CATEGORY") {
|
||||
setSelectedCategory("");
|
||||
}
|
||||
}, [pageTypeFilter]);
|
||||
|
||||
if (slotsLoading) {
|
||||
return <AdSlotsOverviewSkeleton />;
|
||||
}
|
||||
|
||||
if (slotsError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600">Failed to load ad slots overview</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-5 px-10">
|
||||
{/* Summary Cards */}
|
||||
{slotsData && (
|
||||
<div className="grid grid-cols-12 gap-5 mb-8">
|
||||
<div className="col-span-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Total Slots
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{slotsData.totalSlots}</p>
|
||||
</div>
|
||||
<Layout className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Free Slots
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{slotsData.freeSlots}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-green-500 rounded-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Occupied Slots
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-600">
|
||||
{slotsData.occupiedSlots}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-red-500 rounded-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date Navigation and Filters */}
|
||||
<div className="grid grid-cols-12 gap-5 mb-6">
|
||||
<div className="col-span-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Date Navigation:</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousDate}
|
||||
disabled={
|
||||
!availableDates ||
|
||||
availableDates.dates.indexOf(selectedDate) === 0
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Select value={selectedDate} onValueChange={setSelectedDate}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDates?.dates.map((date) => (
|
||||
<SelectItem key={date} value={date}>
|
||||
{new Date(date + "T00:00:00").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextDate}
|
||||
disabled={
|
||||
!availableDates ||
|
||||
availableDates.dates.indexOf(selectedDate) ===
|
||||
availableDates.dates.length - 1
|
||||
}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Page Type:</span>
|
||||
</div>
|
||||
<Select
|
||||
value={pageTypeFilter}
|
||||
onValueChange={(value: PageTypeFilter) => setPageTypeFilter(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HOME">Home Page</SelectItem>
|
||||
<SelectItem value="CATEGORY">Category Pages</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Category:</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={setSelectedCategory}
|
||||
disabled={pageTypeFilter !== "CATEGORY"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* <SelectItem value="">All Categories</SelectItem> */}
|
||||
{availableCategories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
{/* Left Column - Ad Slots */}
|
||||
<div className="col-span-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Grid className="h-5 w-5" />
|
||||
Ad Slots
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{selectedDate &&
|
||||
new Date(selectedDate + "T00:00:00").toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}
|
||||
)}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{slotsData && slotsData.slots.length > 0 ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{slotsData.slots.map((slot) => {
|
||||
const status = getSlotStatus(slot);
|
||||
return (
|
||||
<Card
|
||||
key={`${slot.pageType}-${slot.side}-${slot.position}`}
|
||||
className={`cursor-pointer transition-all duration-200 hover:shadow-md ${getSlotStatusColor(
|
||||
slot
|
||||
)}`}
|
||||
onClick={() => handleSlotClick(slot)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
Pos {slot.position}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{slot.pageType} - {slot.side?.replace("_", " ")}
|
||||
</div>
|
||||
|
||||
{slot.categories && slot.categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{slot.categories.slice(0, 2).map((cat) => (
|
||||
<Badge
|
||||
key={cat.id}
|
||||
className={`text-xs ${getCategoryBadgeColor(
|
||||
cat
|
||||
)}`}
|
||||
>
|
||||
{cat.name}
|
||||
</Badge>
|
||||
))}
|
||||
{slot.categories.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{slot.categories.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{slot.activeAdsCount}/{slot.maxCapacity} ads
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
status === "FREE"
|
||||
? "bg-green-500"
|
||||
: status === "PARTIALLY_OCCUPIED"
|
||||
? "bg-yellow-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
style={{
|
||||
width: `${
|
||||
(slot.activeAdsCount / slot.maxCapacity) *
|
||||
100
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Grid className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
No ad slots found for {selectedDate}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Line Ads */}
|
||||
<div className="col-span-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Line Ads ({lineAdsData?.totalCount || 0})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{lineAdsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : lineAdsData ? (
|
||||
<div className="space-y-3">
|
||||
{[...lineAdsData.homeAds, ...lineAdsData.categoryAds]
|
||||
.slice(0, 6)
|
||||
.map((ad) => (
|
||||
<Card
|
||||
key={ad.id}
|
||||
className="hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-sm line-clamp-1">
|
||||
{ad.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||
{ad.content}
|
||||
</div>
|
||||
|
||||
{ad.mainCategory && (
|
||||
<Badge
|
||||
className={`text-xs ${getCategoryBadgeColor(
|
||||
ad.mainCategory
|
||||
)}`}
|
||||
>
|
||||
{ad.mainCategory.name}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge className={getStatusBadgeColor(ad.status)}>
|
||||
{ad.status}
|
||||
</Badge>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{ad.pageType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
No line ads found for {selectedDate}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slot Details Modal */}
|
||||
<Dialog open={slotDetailsOpen} onOpenChange={setSlotDetailsOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Slot Details - {selectedSlot?.pageType} Page,{" "}
|
||||
{selectedSlot?.side?.replace("_", " ")}, Position{" "}
|
||||
{selectedSlot?.position}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{slotDetailsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : slotDetails ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current Occupancy
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{slotDetails.currentOccupancy}/{slotDetails.maxCapacity}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
<Badge variant="outline">
|
||||
{slotDetails.currentOccupancy === 0
|
||||
? "FREE"
|
||||
: slotDetails.currentOccupancy >= slotDetails.maxCapacity
|
||||
? "FULL"
|
||||
: "PARTIALLY OCCUPIED"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Ads</p>
|
||||
<p className="text-xl font-bold">{slotDetails.ads.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold">
|
||||
Active Ads in this Slot
|
||||
</h4>
|
||||
{slotDetails.ads.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{slotDetails.ads.map((ad, index) => (
|
||||
<Card key={`${ad.id}-${index}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-5 gap-4 items-center">
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">{ad.title}</span>
|
||||
{ad.mainCategory && (
|
||||
<div className="mt-1">
|
||||
<Badge
|
||||
className={`text-xs ${getCategoryBadgeColor(
|
||||
ad.mainCategory
|
||||
)}`}
|
||||
>
|
||||
{ad.mainCategory.name}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant="outline">{ad.adType}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<Badge className={getStatusBadgeColor(ad.status)}>
|
||||
{ad.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{ad.isActive ? "Active" : "Inactive"}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">
|
||||
No ads in this slot on {selectedDate}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-600">Failed to load slot details</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdSlotsOverviewSkeleton() {
|
||||
return (
|
||||
<div className="pt-5 px-10">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-12 gap-5 mb-8">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="col-span-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid grid-cols-12 gap-5 mb-6">
|
||||
<div className="col-span-4">
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Skeleton className="h-4 w-20 mb-2" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
<div className="col-span-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { ArrowUpDown, Eye, Edit, FileImage, Calendar } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import type { LineAd } from "@/lib/types/lineAd";
|
||||
import { EditAdLink } from "@/components/mgmt/EditAdLink";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Popover,
|
||||
@@ -407,10 +409,10 @@ export const columns: ColumnDef<LineAd>[] = [
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/mgmt/dashboard/review-ads/line/edit/${ad.id}`}>
|
||||
<EditAdLink adId={ad.id} adType={AdType.LINE} from="ads-on-hold">
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Link>
|
||||
</EditAdLink>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ import Image from "next/image";
|
||||
import Zoom from "react-medium-image-zoom";
|
||||
import { PaymentDetailsDialog } from "@/components/payment/payment-details-dialog";
|
||||
import { PosterAd } from "@/lib/types/posterAd";
|
||||
import { EditAdLink } from "@/components/mgmt/EditAdLink";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
|
||||
export const columns: ColumnDef<PosterAd>[] = [
|
||||
{
|
||||
@@ -320,10 +322,10 @@ export const columns: ColumnDef<PosterAd>[] = [
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/mgmt/dashboard/review-ads/poster/edit/${ad.id}`}>
|
||||
<EditAdLink adId={ad.id} adType={AdType.POSTER} from="ads-on-hold">
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Link>
|
||||
</EditAdLink>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -71,8 +71,8 @@ export default function AdsOnHoldPosterAdsPage() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={ads}
|
||||
searchColumn="title"
|
||||
searchPlaceholder="Search title..."
|
||||
searchColumn="dates"
|
||||
searchPlaceholder="Search dates..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { VideoAd } from "@/lib/types/videoAd";
|
||||
import { EditAdLink } from "@/components/mgmt/EditAdLink";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
import { getStatusVariant } from "@/lib/utils";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
@@ -117,8 +119,8 @@ export const columns: ColumnDef<VideoAd>[] = [
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <div className="">{row.original.position.replace("_", " ")}</div>;
|
||||
cell: ({ row }) => {
|
||||
return <div className="">{row.original.position.pageType}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -317,10 +319,10 @@ export const columns: ColumnDef<VideoAd>[] = [
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/mgmt/dashboard/review-ads/video/edit/${ad.id}`}>
|
||||
<EditAdLink adId={ad.id} adType={AdType.VIDEO} from="ads-on-hold">
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Link>
|
||||
</EditAdLink>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -71,8 +71,8 @@ export default function AdsOnHoldVideoAdsPage() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={ads}
|
||||
searchColumn="title"
|
||||
searchPlaceholder="Search title..."
|
||||
searchColumn="dates"
|
||||
searchPlaceholder="Search dates..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,23 +29,41 @@ import api from "@/lib/api";
|
||||
import { CategoryColorPicker } from "@/components/mgmt/categories/category-color-picket";
|
||||
import { CategoryTreeForm } from "@/components/mgmt/categories/category-tree-form";
|
||||
|
||||
// Create the form schema based on the DTO
|
||||
const subCategorySchema: z.ZodType<{
|
||||
name: string;
|
||||
category_heading_font_color: string;
|
||||
subCategories: any[];
|
||||
}> = z.lazy(() =>
|
||||
z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
category_heading_font_color: z
|
||||
.string()
|
||||
.regex(
|
||||
/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
"Valid color code is required"
|
||||
),
|
||||
subCategories: z.array(subCategorySchema),
|
||||
})
|
||||
);
|
||||
// Level 3 schema (no subcategories allowed)
|
||||
const levelThreeSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
category_heading_font_color: z
|
||||
.string()
|
||||
.regex(
|
||||
/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
"Valid color code is required"
|
||||
),
|
||||
subCategories: z.array(z.never()).length(0, "Level 3 categories cannot have subcategories"),
|
||||
});
|
||||
|
||||
// Level 2 schema (can have level 3 subcategories)
|
||||
const levelTwoSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
category_heading_font_color: z
|
||||
.string()
|
||||
.regex(
|
||||
/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
"Valid color code is required"
|
||||
),
|
||||
subCategories: z.array(levelThreeSchema),
|
||||
});
|
||||
|
||||
// Level 1 schema (can have level 2 subcategories)
|
||||
const levelOneSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
category_heading_font_color: z
|
||||
.string()
|
||||
.regex(
|
||||
/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
"Valid color code is required"
|
||||
),
|
||||
subCategories: z.array(levelTwoSchema),
|
||||
});
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
@@ -67,7 +85,7 @@ const formSchema = z.object({
|
||||
/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
|
||||
"Valid color code is required"
|
||||
),
|
||||
subCategories: z.array(subCategorySchema),
|
||||
subCategories: z.array(levelOneSchema),
|
||||
});
|
||||
|
||||
export type CategoryFormValues = z.infer<typeof formSchema>;
|
||||
@@ -108,10 +126,12 @@ export default function AddCategoryPage() {
|
||||
createCategoryMutation.mutate(values);
|
||||
}
|
||||
|
||||
console.log(form.formState.errors)
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Add New Category Tree</h1>
|
||||
<h1 className="text-2xl font-bold">Add New Category </h1>
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
Back to Categories
|
||||
</Button>
|
||||
@@ -122,9 +142,6 @@ export default function AddCategoryPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Main Category Details</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new main category with associated subcategories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
600
src/app/mgmt/dashboard/configurations/about-us/page.tsx
Normal file
600
src/app/mgmt/dashboard/configurations/about-us/page.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Users, Save, Plus, Trash2, Eye, AlertCircle, Building, Target, Lightbulb, Award } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import api from "@/lib/api";
|
||||
|
||||
interface TeamMember {
|
||||
name: string;
|
||||
position: string;
|
||||
bio: string;
|
||||
imageUrl?: string;
|
||||
socialLinks?: {
|
||||
linkedin?: string;
|
||||
twitter?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Achievement {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface AboutUs {
|
||||
_id?: string;
|
||||
companyOverview: string;
|
||||
mission: string;
|
||||
vision: string;
|
||||
values: string[];
|
||||
history: string;
|
||||
teamMembers: TeamMember[];
|
||||
achievements: Achievement[];
|
||||
contactInfo: {
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
};
|
||||
isActive: boolean;
|
||||
lastUpdated: Date;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
interface AboutUsForm {
|
||||
companyOverview: string;
|
||||
mission: string;
|
||||
vision: string;
|
||||
values: string[];
|
||||
history: string;
|
||||
teamMembers: TeamMember[];
|
||||
achievements: Achievement[];
|
||||
contactInfo: {
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AboutUsConfig() {
|
||||
const [formData, setFormData] = useState<AboutUsForm>({
|
||||
companyOverview: "",
|
||||
mission: "",
|
||||
vision: "",
|
||||
values: [""],
|
||||
history: "",
|
||||
teamMembers: [],
|
||||
achievements: [],
|
||||
contactInfo: {
|
||||
email: "",
|
||||
phone: "",
|
||||
address: ""
|
||||
}
|
||||
});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get current about us data
|
||||
const { data: currentAboutUs, isLoading, error } = useQuery({
|
||||
queryKey: ["about-us"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/about-us");
|
||||
return data as AboutUs;
|
||||
}
|
||||
});
|
||||
|
||||
// Update about us mutation
|
||||
const updateAboutUsMutation = useMutation({
|
||||
mutationFn: async (aboutUsData: AboutUsForm) => {
|
||||
const { data } = await api.post("/configurations/about-us", aboutUsData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("About Us page updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["about-us"] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Failed to update About Us page");
|
||||
}
|
||||
});
|
||||
|
||||
// Update form when current data loads
|
||||
useEffect(() => {
|
||||
if (currentAboutUs) {
|
||||
setFormData({
|
||||
companyOverview: currentAboutUs.companyOverview,
|
||||
mission: currentAboutUs.mission,
|
||||
vision: currentAboutUs.vision,
|
||||
values: currentAboutUs.values.length > 0 ? currentAboutUs.values : [""],
|
||||
history: currentAboutUs.history,
|
||||
teamMembers: currentAboutUs.teamMembers,
|
||||
achievements: currentAboutUs.achievements,
|
||||
contactInfo: currentAboutUs.contactInfo
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [currentAboutUs]);
|
||||
|
||||
const handleFormChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleNestedChange = (field: string, nestedField: string, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: {
|
||||
// @ts-ignore
|
||||
...prev[field as keyof AboutUsForm],
|
||||
[nestedField]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleArrayChange = (field: "values" | "teamMembers" | "achievements", index: number, value: any) => {
|
||||
setFormData(prev => {
|
||||
const newArray = [...prev[field]];
|
||||
newArray[index] = value;
|
||||
return { ...prev, [field]: newArray };
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const addArrayItem = (field: "values" | "teamMembers" | "achievements") => {
|
||||
setFormData(prev => {
|
||||
let newItem;
|
||||
switch (field) {
|
||||
case "values":
|
||||
newItem = "";
|
||||
break;
|
||||
case "teamMembers":
|
||||
newItem = { name: "", position: "", bio: "", socialLinks: {} };
|
||||
break;
|
||||
case "achievements":
|
||||
newItem = { title: "", description: "", date: "" };
|
||||
break;
|
||||
}
|
||||
return { ...prev, [field]: [...prev[field], newItem] };
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const removeArrayItem = (field: "values" | "teamMembers" | "achievements", index: number) => {
|
||||
setFormData(prev => {
|
||||
const newArray = prev[field].filter((_, i) => i !== index);
|
||||
return { ...prev, [field]: newArray };
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Filter out empty values
|
||||
const cleanedData = {
|
||||
...formData,
|
||||
values: formData.values.filter(value => value.trim() !== "")
|
||||
};
|
||||
updateAboutUsMutation.mutate(cleanedData);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load About Us configuration. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* About Us Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
About Us Page Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Company Overview */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Company Overview</h3>
|
||||
</div>
|
||||
<Textarea
|
||||
value={formData.companyOverview}
|
||||
onChange={(e) => handleFormChange("companyOverview", e.target.value)}
|
||||
placeholder="Describe your company's main purpose and what you do..."
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formData.companyOverview.length}/1000 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mission & Vision */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Mission Statement
|
||||
</Label>
|
||||
<Textarea
|
||||
value={formData.mission}
|
||||
onChange={(e) => handleFormChange("mission", e.target.value)}
|
||||
placeholder="What is your company's mission?"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formData.mission.length}/500 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
Vision Statement
|
||||
</Label>
|
||||
<Textarea
|
||||
value={formData.vision}
|
||||
onChange={(e) => handleFormChange("vision", e.target.value)}
|
||||
placeholder="What is your company's vision for the future?"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formData.vision.length}/500 characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company Values */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<h3 className="text-lg font-semibold">Company Values</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addArrayItem("values")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Value
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{formData.values.map((value, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleArrayChange("values", index, e.target.value)}
|
||||
placeholder={`Company value ${index + 1}`}
|
||||
maxLength={100}
|
||||
/>
|
||||
{formData.values.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeArrayItem("values", index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company History */}
|
||||
<div className="space-y-2">
|
||||
<Label>Company History</Label>
|
||||
<Textarea
|
||||
value={formData.history}
|
||||
onChange={(e) => handleFormChange("history", e.target.value)}
|
||||
placeholder="Tell the story of how your company was founded and evolved..."
|
||||
rows={4}
|
||||
maxLength={1500}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formData.history.length}/1500 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<h3 className="text-lg font-semibold">Team Members</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addArrayItem("teamMembers")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Team Member
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{formData.teamMembers.map((member, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">Team Member {index + 1}</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeArrayItem("teamMembers", index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
value={member.name}
|
||||
onChange={(e) => handleArrayChange("teamMembers", index, { ...member, name: e.target.value })}
|
||||
placeholder="Full Name"
|
||||
/>
|
||||
<Input
|
||||
value={member.position}
|
||||
onChange={(e) => handleArrayChange("teamMembers", index, { ...member, position: e.target.value })}
|
||||
placeholder="Job Title/Position"
|
||||
/>
|
||||
<Input
|
||||
value={member.imageUrl || ""}
|
||||
onChange={(e) => handleArrayChange("teamMembers", index, { ...member, imageUrl: e.target.value })}
|
||||
placeholder="Profile Image URL (optional)"
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
<Textarea
|
||||
value={member.bio}
|
||||
onChange={(e) => handleArrayChange("teamMembers", index, { ...member, bio: e.target.value })}
|
||||
placeholder="Brief biography..."
|
||||
rows={2}
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Achievements */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Award className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Company Achievements</h3>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addArrayItem("achievements")}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Achievement
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{formData.achievements.map((achievement, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium">Achievement {index + 1}</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeArrayItem("achievements", index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
value={achievement.title}
|
||||
onChange={(e) => handleArrayChange("achievements", index, { ...achievement, title: e.target.value })}
|
||||
placeholder="Achievement Title"
|
||||
className="md:col-span-2"
|
||||
/>
|
||||
<Input
|
||||
value={achievement.date}
|
||||
onChange={(e) => handleArrayChange("achievements", index, { ...achievement, date: e.target.value })}
|
||||
placeholder="Date (e.g., 2024)"
|
||||
/>
|
||||
<Textarea
|
||||
value={achievement.description}
|
||||
onChange={(e) => handleArrayChange("achievements", index, { ...achievement, description: e.target.value })}
|
||||
placeholder="Description of the achievement..."
|
||||
rows={2}
|
||||
className="md:col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Contact Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Company Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.contactInfo.email}
|
||||
onChange={(e) => handleNestedChange("contactInfo", "email", e.target.value)}
|
||||
placeholder="company@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Phone Number</Label>
|
||||
<Input
|
||||
value={formData.contactInfo.phone}
|
||||
onChange={(e) => handleNestedChange("contactInfo", "phone", e.target.value)}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Address</Label>
|
||||
<Input
|
||||
value={formData.contactInfo.address}
|
||||
onChange={(e) => handleNestedChange("contactInfo", "address", e.target.value)}
|
||||
placeholder="Company address"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{showPreview ? "Hide" : "Show"} Preview
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateAboutUsMutation.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updateAboutUsMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Updated Info */}
|
||||
{currentAboutUs && (
|
||||
<div className="text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
Last updated: {format(new Date(currentAboutUs.lastUpdated), "PPpp")}
|
||||
</span>
|
||||
<span>
|
||||
Updated by: {currentAboutUs.updatedBy}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About Us Page Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{/* Company Overview */}
|
||||
{formData.companyOverview && (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">About Our Company</h2>
|
||||
<p className="text-muted-foreground leading-relaxed">{formData.companyOverview}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mission & Vision */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{formData.mission && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2 flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
Our Mission
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{formData.mission}</p>
|
||||
</div>
|
||||
)}
|
||||
{formData.vision && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2 flex items-center gap-2">
|
||||
<Lightbulb className="h-5 w-5" />
|
||||
Our Vision
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{formData.vision}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
{formData.values.filter(v => v.trim()).length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4">Our Values</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{formData.values.filter(v => v.trim()).map((value, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{value}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Members */}
|
||||
{formData.teamMembers.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4">Our Team</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{formData.teamMembers.map((member, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-center">
|
||||
<h4 className="font-semibold">{member.name}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">{member.position}</p>
|
||||
<p className="text-xs">{member.bio}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
351
src/app/mgmt/dashboard/configurations/ad-pricing/page.tsx
Normal file
351
src/app/mgmt/dashboard/configurations/ad-pricing/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { IndianRupee, Save, History, TrendingUp, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import api from "@/lib/api";
|
||||
|
||||
interface AdPricing {
|
||||
_id?: string;
|
||||
lineAdPrice: number;
|
||||
posterAdPrice: number;
|
||||
videoAdPrice: number;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
lastUpdated: Date;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
interface AdPricingForm {
|
||||
lineAdPrice: number;
|
||||
posterAdPrice: number;
|
||||
videoAdPrice: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export default function AdPricingConfig() {
|
||||
const [formData, setFormData] = useState<AdPricingForm>({
|
||||
lineAdPrice: 0,
|
||||
posterAdPrice: 0,
|
||||
videoAdPrice: 0,
|
||||
currency: "INR"
|
||||
});
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get current ad pricing
|
||||
const { data: currentPricing, isLoading, error } = useQuery({
|
||||
queryKey: ["ad-pricing"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/ad-pricing");
|
||||
return data as AdPricing;
|
||||
}
|
||||
});
|
||||
|
||||
// Get pricing history
|
||||
const { data: pricingHistory, isLoading: historyLoading } = useQuery({
|
||||
queryKey: ["ad-pricing-history"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/ad-pricing/history");
|
||||
return data as AdPricing[];
|
||||
},
|
||||
enabled: showHistory
|
||||
});
|
||||
|
||||
// Update ad pricing mutation
|
||||
const updatePricingMutation = useMutation({
|
||||
mutationFn: async (pricingData: AdPricingForm) => {
|
||||
const { data } = await api.post("/configurations/ad-pricing", pricingData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Ad pricing updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["ad-pricing"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ad-pricing-history"] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Failed to update ad pricing");
|
||||
}
|
||||
});
|
||||
|
||||
// Update form when current pricing loads
|
||||
useEffect(() => {
|
||||
if (currentPricing) {
|
||||
setFormData({
|
||||
lineAdPrice: currentPricing.lineAdPrice,
|
||||
posterAdPrice: currentPricing.posterAdPrice,
|
||||
videoAdPrice: currentPricing.videoAdPrice,
|
||||
currency: currentPricing.currency
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [currentPricing]);
|
||||
|
||||
const handleFormChange = (field: keyof AdPricingForm, value: string | number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updatePricingMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = "INR") => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load ad pricing configuration. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Pricing Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<IndianRupee className="h-5 w-5" />
|
||||
Ad Pricing Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-20" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Current Pricing Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="bg-blue-50 border-blue-200">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-600">Line Ads</p>
|
||||
<p className="text-2xl font-bold text-blue-800">
|
||||
{formatCurrency(currentPricing?.lineAdPrice || 0, currentPricing?.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-green-50 border-green-200">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-600">Poster Ads</p>
|
||||
<p className="text-2xl font-bold text-green-800">
|
||||
{formatCurrency(currentPricing?.posterAdPrice || 0, currentPricing?.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-purple-50 border-purple-200">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-purple-600">Video Ads</p>
|
||||
<p className="text-2xl font-bold text-purple-800">
|
||||
{formatCurrency(currentPricing?.videoAdPrice || 0, currentPricing?.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Edit Form */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lineAdPrice">Line Ad Price</Label>
|
||||
<Input
|
||||
id="lineAdPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={formData.lineAdPrice}
|
||||
onChange={(e) => handleFormChange("lineAdPrice", parseFloat(e.target.value) || 0)}
|
||||
placeholder="Enter line ad price"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="posterAdPrice">Poster Ad Price</Label>
|
||||
<Input
|
||||
id="posterAdPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={formData.posterAdPrice}
|
||||
onChange={(e) => handleFormChange("posterAdPrice", parseFloat(e.target.value) || 0)}
|
||||
placeholder="Enter poster ad price"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="videoAdPrice">Video Ad Price</Label>
|
||||
<Input
|
||||
id="videoAdPrice"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={formData.videoAdPrice}
|
||||
onChange={(e) => handleFormChange("videoAdPrice", parseFloat(e.target.value) || 0)}
|
||||
placeholder="Enter video ad price"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(value) => handleFormChange("currency", value)}
|
||||
disabled
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="INR">INR - Indian Rupee</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
{showHistory ? "Hide" : "Show"} History
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updatePricingMutation.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updatePricingMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Updated Info */}
|
||||
{currentPricing && (
|
||||
<div className="text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
Last updated: {format(new Date(currentPricing.lastUpdated), "PPpp")}
|
||||
</span>
|
||||
<span>
|
||||
Updated by: {currentPricing.updatedBy}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pricing History */}
|
||||
{showHistory && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Pricing History
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{historyLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : pricingHistory && pricingHistory.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{pricingHistory.map((pricing, index) => (
|
||||
<div key={pricing._id || index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={index === 0 ? "default" : "secondary"}>
|
||||
{index === 0 ? "Current" : `Version ${pricingHistory.length - index}`}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{format(new Date(pricing.lastUpdated), "PPpp")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
By: {pricing.updatedBy}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Line Ads:</span> {formatCurrency(pricing.lineAdPrice, pricing.currency)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Poster Ads:</span> {formatCurrency(pricing.posterAdPrice, pricing.currency)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Video Ads:</span> {formatCurrency(pricing.videoAdPrice, pricing.currency)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No pricing history available
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
628
src/app/mgmt/dashboard/configurations/contact-page/page.tsx
Normal file
628
src/app/mgmt/dashboard/configurations/contact-page/page.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Phone, Save, Eye, AlertCircle, Building, Mail, MapPin, Clock, Globe, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import api from "@/lib/api";
|
||||
|
||||
interface BusinessHours {
|
||||
monday: string;
|
||||
tuesday: string;
|
||||
wednesday: string;
|
||||
thursday: string;
|
||||
friday: string;
|
||||
saturday: string;
|
||||
sunday: string;
|
||||
}
|
||||
|
||||
interface Coordinates {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
interface ContactPage {
|
||||
_id?: string;
|
||||
companyName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
alternatePhone?: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
coordinates?: Coordinates;
|
||||
socialMediaLinks: string[];
|
||||
businessHours: BusinessHours;
|
||||
supportEmail?: string;
|
||||
salesEmail?: string;
|
||||
emergencyContact?: string;
|
||||
websiteUrl?: string;
|
||||
isActive: boolean;
|
||||
lastUpdated: Date;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
interface ContactPageForm {
|
||||
companyName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
alternatePhone: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
coordinates: {
|
||||
latitude: number | string;
|
||||
longitude: number | string;
|
||||
};
|
||||
socialMediaLinks: string[];
|
||||
businessHours: BusinessHours;
|
||||
supportEmail: string;
|
||||
salesEmail: string;
|
||||
emergencyContact: string;
|
||||
websiteUrl: string;
|
||||
}
|
||||
|
||||
const DEFAULT_BUSINESS_HOURS: BusinessHours = {
|
||||
monday: "9:00 AM - 6:00 PM",
|
||||
tuesday: "9:00 AM - 6:00 PM",
|
||||
wednesday: "9:00 AM - 6:00 PM",
|
||||
thursday: "9:00 AM - 6:00 PM",
|
||||
friday: "9:00 AM - 6:00 PM",
|
||||
saturday: "10:00 AM - 4:00 PM",
|
||||
sunday: "Closed"
|
||||
};
|
||||
|
||||
export default function ContactPageConfig() {
|
||||
const [formData, setFormData] = useState<ContactPageForm>({
|
||||
companyName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
alternatePhone: "",
|
||||
address: "",
|
||||
city: "",
|
||||
state: "",
|
||||
postalCode: "",
|
||||
country: "",
|
||||
coordinates: {
|
||||
latitude: "",
|
||||
longitude: ""
|
||||
},
|
||||
socialMediaLinks: [""],
|
||||
businessHours: DEFAULT_BUSINESS_HOURS,
|
||||
supportEmail: "",
|
||||
salesEmail: "",
|
||||
emergencyContact: "",
|
||||
websiteUrl: ""
|
||||
});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get current contact page data
|
||||
const { data: currentContact, isLoading, error } = useQuery({
|
||||
queryKey: ["contact-page"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/contact-page");
|
||||
return data as ContactPage;
|
||||
}
|
||||
});
|
||||
|
||||
// Update contact page mutation
|
||||
const updateContactMutation = useMutation({
|
||||
mutationFn: async (contactData: ContactPageForm) => {
|
||||
// Clean up data before sending
|
||||
const cleanedData = {
|
||||
...contactData,
|
||||
coordinates: {
|
||||
latitude: contactData.coordinates.latitude ? Number(contactData.coordinates.latitude) : undefined,
|
||||
longitude: contactData.coordinates.longitude ? Number(contactData.coordinates.longitude) : undefined
|
||||
},
|
||||
socialMediaLinks: contactData.socialMediaLinks.filter(link => link.trim() !== "")
|
||||
};
|
||||
|
||||
const { data } = await api.post("/configurations/contact-page", cleanedData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Contact page updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["contact-page"] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Failed to update contact page");
|
||||
}
|
||||
});
|
||||
|
||||
// Update form when current data loads
|
||||
useEffect(() => {
|
||||
if (currentContact) {
|
||||
setFormData({
|
||||
companyName: currentContact.companyName,
|
||||
email: currentContact.email,
|
||||
phone: currentContact.phone,
|
||||
alternatePhone: currentContact.alternatePhone || "",
|
||||
address: currentContact.address,
|
||||
city: currentContact.city,
|
||||
state: currentContact.state,
|
||||
postalCode: currentContact.postalCode,
|
||||
country: currentContact.country,
|
||||
coordinates: {
|
||||
latitude: currentContact.coordinates?.latitude || "",
|
||||
longitude: currentContact.coordinates?.longitude || ""
|
||||
},
|
||||
socialMediaLinks: currentContact.socialMediaLinks.length > 0 ? currentContact.socialMediaLinks : [""],
|
||||
businessHours: currentContact.businessHours,
|
||||
supportEmail: currentContact.supportEmail || "",
|
||||
salesEmail: currentContact.salesEmail || "",
|
||||
emergencyContact: currentContact.emergencyContact || "",
|
||||
websiteUrl: currentContact.websiteUrl || ""
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [currentContact]);
|
||||
|
||||
const handleFormChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleNestedChange = (field: string, nestedField: string, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: {
|
||||
// @ts-ignore
|
||||
...prev[field as keyof ContactPageForm],
|
||||
[nestedField]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleBusinessHoursChange = (day: keyof BusinessHours, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
businessHours: {
|
||||
...prev.businessHours,
|
||||
[day]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSocialLinksChange = (index: number, value: string) => {
|
||||
setFormData(prev => {
|
||||
const newLinks = [...prev.socialMediaLinks];
|
||||
newLinks[index] = value;
|
||||
return { ...prev, socialMediaLinks: newLinks };
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const addSocialLink = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
socialMediaLinks: [...prev.socialMediaLinks, ""]
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const removeSocialLink = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
socialMediaLinks: prev.socialMediaLinks.filter((_, i) => i !== index)
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateContactMutation.mutate(formData);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load contact page configuration. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Contact Page Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Phone className="h-5 w-5" />
|
||||
Contact Page Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Company Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Company Information</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Company Name</Label>
|
||||
<Input
|
||||
value={formData.companyName}
|
||||
onChange={(e) => handleFormChange("companyName", e.target.value)}
|
||||
placeholder="Your Company Name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Website URL</Label>
|
||||
<Input
|
||||
value={formData.websiteUrl}
|
||||
onChange={(e) => handleFormChange("websiteUrl", e.target.value)}
|
||||
placeholder="https://www.yourcompany.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Contact Details</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Primary Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
placeholder="contact@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Primary Phone</Label>
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleFormChange("phone", e.target.value)}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Support Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.supportEmail}
|
||||
onChange={(e) => handleFormChange("supportEmail", e.target.value)}
|
||||
placeholder="support@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Sales Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.salesEmail}
|
||||
onChange={(e) => handleFormChange("salesEmail", e.target.value)}
|
||||
placeholder="sales@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Alternate Phone</Label>
|
||||
<Input
|
||||
value={formData.alternatePhone}
|
||||
onChange={(e) => handleFormChange("alternatePhone", e.target.value)}
|
||||
placeholder="+1 (555) 987-6543"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Emergency Contact</Label>
|
||||
<Input
|
||||
value={formData.emergencyContact}
|
||||
onChange={(e) => handleFormChange("emergencyContact", e.target.value)}
|
||||
placeholder="+1 (555) 911-0000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Address Information</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Street Address</Label>
|
||||
<Textarea
|
||||
value={formData.address}
|
||||
onChange={(e) => handleFormChange("address", e.target.value)}
|
||||
placeholder="123 Main Street, Suite 100"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>City</Label>
|
||||
<Input
|
||||
value={formData.city}
|
||||
onChange={(e) => handleFormChange("city", e.target.value)}
|
||||
placeholder="City"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>State/Province</Label>
|
||||
<Input
|
||||
value={formData.state}
|
||||
onChange={(e) => handleFormChange("state", e.target.value)}
|
||||
placeholder="State"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Postal Code</Label>
|
||||
<Input
|
||||
value={formData.postalCode}
|
||||
onChange={(e) => handleFormChange("postalCode", e.target.value)}
|
||||
placeholder="12345"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Country</Label>
|
||||
<Input
|
||||
value={formData.country}
|
||||
onChange={(e) => handleFormChange("country", e.target.value)}
|
||||
placeholder="Country"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Latitude (Optional)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.coordinates.latitude}
|
||||
onChange={(e) => handleNestedChange("coordinates", "latitude", e.target.value)}
|
||||
placeholder="40.7128"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Longitude (Optional)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={formData.coordinates.longitude}
|
||||
onChange={(e) => handleNestedChange("coordinates", "longitude", e.target.value)}
|
||||
placeholder="-74.0060"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Hours */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Business Hours</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(formData.businessHours).map(([day, hours]) => (
|
||||
<div key={day} className="space-y-2">
|
||||
<Label className="capitalize">{day}</Label>
|
||||
<Input
|
||||
value={hours}
|
||||
onChange={(e) => handleBusinessHoursChange(day as keyof BusinessHours, e.target.value)}
|
||||
placeholder="9:00 AM - 5:00 PM or Closed"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Media Links */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Social Media Links</h3>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addSocialLink}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Link
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{formData.socialMediaLinks.map((link, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={link}
|
||||
onChange={(e) => handleSocialLinksChange(index, e.target.value)}
|
||||
placeholder="https://www.facebook.com/yourcompany"
|
||||
/>
|
||||
{formData.socialMediaLinks.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeSocialLink(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{showPreview ? "Hide" : "Show"} Preview
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateContactMutation.isPending || !formData.companyName.trim()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updateContactMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Updated Info */}
|
||||
{currentContact && (
|
||||
<div className="text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
Last updated: {format(new Date(currentContact.lastUpdated), "PPpp")}
|
||||
</span>
|
||||
<span>
|
||||
Updated by: {currentContact.updatedBy}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Contact Page Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-2">Contact {formData.companyName}</h2>
|
||||
<p className="text-muted-foreground">Get in touch with us through any of the following methods</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Methods */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Email */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="h-6 w-6 text-blue-600" />
|
||||
<div>
|
||||
<h4 className="font-medium">Email Us</h4>
|
||||
<p className="text-sm text-muted-foreground">{formData.email}</p>
|
||||
{formData.supportEmail && (
|
||||
<p className="text-xs text-muted-foreground">Support: {formData.supportEmail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Phone */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Phone className="h-6 w-6 text-green-600" />
|
||||
<div>
|
||||
<h4 className="font-medium">Call Us</h4>
|
||||
<p className="text-sm text-muted-foreground">{formData.phone}</p>
|
||||
{formData.alternatePhone && (
|
||||
<p className="text-xs text-muted-foreground">Alt: {formData.alternatePhone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPin className="h-6 w-6 text-red-600" />
|
||||
<div>
|
||||
<h4 className="font-medium">Visit Us</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formData.address}<br />
|
||||
{formData.city}, {formData.state} {formData.postalCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Business Hours */}
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Business Hours
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{Object.entries(formData.businessHours).map(([day, hours]) => (
|
||||
<div key={day} className="flex justify-between py-1">
|
||||
<span className="capitalize font-medium">{day}:</span>
|
||||
<span className="text-muted-foreground">{hours}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Media */}
|
||||
{formData.socialMediaLinks.filter(link => link.trim()).length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-4">Follow Us</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.socialMediaLinks.filter(link => link.trim()).map((link, index) => (
|
||||
<Badge key={index} variant="outline" className="p-2">
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
{new URL(link).hostname}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
565
src/app/mgmt/dashboard/configurations/faq/page.tsx
Normal file
565
src/app/mgmt/dashboard/configurations/faq/page.tsx
Normal file
@@ -0,0 +1,565 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HelpCircle, Save, Plus, Trash2, Eye, AlertCircle, ArrowUp, ArrowDown, Tag } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import api from "@/lib/api";
|
||||
|
||||
interface FaqQuestion {
|
||||
question: string;
|
||||
answer: string;
|
||||
category: string;
|
||||
order: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Faq {
|
||||
_id?: string;
|
||||
questions: FaqQuestion[];
|
||||
categories: string[];
|
||||
introduction: string;
|
||||
contactInfo: {
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
isActive: boolean;
|
||||
lastUpdated: Date;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
interface FaqForm {
|
||||
questions: FaqQuestion[];
|
||||
categories: string[];
|
||||
introduction: string;
|
||||
contactInfo: {
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function FaqConfig() {
|
||||
const [formData, setFormData] = useState<FaqForm>({
|
||||
questions: [],
|
||||
categories: ["General"],
|
||||
introduction: "",
|
||||
contactInfo: {
|
||||
email: "",
|
||||
phone: ""
|
||||
}
|
||||
});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("All");
|
||||
const [newCategory, setNewCategory] = useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get current FAQ data
|
||||
const { data: currentFaq, isLoading, error } = useQuery({
|
||||
queryKey: ["faq"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/faq");
|
||||
return data as Faq;
|
||||
}
|
||||
});
|
||||
|
||||
// Update FAQ mutation
|
||||
const updateFaqMutation = useMutation({
|
||||
mutationFn: async (faqData: FaqForm) => {
|
||||
const { data } = await api.post("/configurations/faq", faqData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("FAQ updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["faq"] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Failed to update FAQ");
|
||||
}
|
||||
});
|
||||
|
||||
// Add new question mutation
|
||||
const addQuestionMutation = useMutation({
|
||||
mutationFn: async (questionData: Omit<FaqQuestion, 'order'>) => {
|
||||
const { data } = await api.post("/configurations/faq/question", questionData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("FAQ question added successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["faq"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Failed to add FAQ question");
|
||||
}
|
||||
});
|
||||
|
||||
// Update form when current data loads
|
||||
useEffect(() => {
|
||||
if (currentFaq) {
|
||||
setFormData({
|
||||
questions: currentFaq.questions.sort((a, b) => a.order - b.order),
|
||||
categories: currentFaq.categories,
|
||||
introduction: currentFaq.introduction,
|
||||
contactInfo: currentFaq.contactInfo
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [currentFaq]);
|
||||
|
||||
const handleFormChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleNestedChange = (field: string, nestedField: string, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: {
|
||||
// @ts-ignore
|
||||
...prev[field as keyof FaqForm],
|
||||
[nestedField]: value
|
||||
}
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleQuestionChange = (index: number, field: keyof FaqQuestion, value: any) => {
|
||||
setFormData(prev => {
|
||||
const newQuestions = [...prev.questions];
|
||||
newQuestions[index] = { ...newQuestions[index], [field]: value };
|
||||
return { ...prev, questions: newQuestions };
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const addQuestion = () => {
|
||||
const newQuestion: FaqQuestion = {
|
||||
question: "",
|
||||
answer: "",
|
||||
category: formData.categories[0] || "General",
|
||||
order: formData.questions.length,
|
||||
isActive: true
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
questions: [...prev.questions, newQuestion]
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const removeQuestion = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
questions: prev.questions.filter((_, i) => i !== index).map((q, i) => ({ ...q, order: i }))
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const moveQuestion = (index: number, direction: 'up' | 'down') => {
|
||||
setFormData(prev => {
|
||||
const newQuestions = [...prev.questions];
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
|
||||
if (targetIndex >= 0 && targetIndex < newQuestions.length) {
|
||||
[newQuestions[index], newQuestions[targetIndex]] = [newQuestions[targetIndex], newQuestions[index]];
|
||||
// Update order numbers
|
||||
newQuestions.forEach((q, i) => {
|
||||
q.order = i;
|
||||
});
|
||||
}
|
||||
|
||||
return { ...prev, questions: newQuestions };
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
if (newCategory.trim() && !formData.categories.includes(newCategory.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
categories: [...prev.categories, newCategory.trim()]
|
||||
}));
|
||||
setNewCategory("");
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCategory = (category: string) => {
|
||||
if (formData.categories.length > 1) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
categories: prev.categories.filter(c => c !== category),
|
||||
questions: prev.questions.map(q =>
|
||||
q.category === category
|
||||
? { ...q, category: prev.categories.find(c => c !== category) || "General" }
|
||||
: q
|
||||
)
|
||||
}));
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateFaqMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const filteredQuestions = selectedCategory === "All"
|
||||
? formData.questions
|
||||
: formData.questions.filter(q => q.category === selectedCategory);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load FAQ configuration. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* FAQ Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
FAQ Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Introduction */}
|
||||
<div className="space-y-2">
|
||||
<Label>FAQ Page Introduction</Label>
|
||||
<Textarea
|
||||
value={formData.introduction}
|
||||
onChange={(e) => handleFormChange("introduction", e.target.value)}
|
||||
placeholder="Welcome to our FAQ section. Here you'll find answers to commonly asked questions..."
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formData.introduction.length}/500 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Management */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Tag className="h-5 w-5" />
|
||||
Categories
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.categories.map(category => (
|
||||
<div key={category} className="flex items-center gap-1">
|
||||
<Badge variant="secondary">{category}</Badge>
|
||||
{formData.categories.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeCategory(category)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newCategory}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
placeholder="New category name"
|
||||
onKeyPress={(e) => e.key === 'Enter' && addCategory()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addCategory}
|
||||
disabled={!newCategory.trim()}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Additional Help Contact</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Support Email</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.contactInfo.email}
|
||||
onChange={(e) => handleNestedChange("contactInfo", "email", e.target.value)}
|
||||
placeholder="support@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Support Phone</Label>
|
||||
<Input
|
||||
value={formData.contactInfo.phone}
|
||||
onChange={(e) => handleNestedChange("contactInfo", "phone", e.target.value)}
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Questions Management */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">FAQ Questions</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All Categories</SelectItem>
|
||||
{formData.categories.map(category => (
|
||||
<SelectItem key={category} value={category}>{category}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addQuestion}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Question
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{filteredQuestions.map((question, displayIndex) => {
|
||||
const actualIndex = formData.questions.findIndex(q => q === question);
|
||||
return (
|
||||
<Card key={actualIndex} className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">#{question.order + 1}</Badge>
|
||||
<Badge variant="secondary">{question.category}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Switch
|
||||
checked={question.isActive}
|
||||
onCheckedChange={(checked) => handleQuestionChange(actualIndex, "isActive", checked)}
|
||||
/>
|
||||
<span className="text-sm">Active</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveQuestion(actualIndex, 'up')}
|
||||
disabled={actualIndex === 0}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveQuestion(actualIndex, 'down')}
|
||||
disabled={actualIndex === formData.questions.length - 1}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeQuestion(actualIndex)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Question</Label>
|
||||
<Textarea
|
||||
value={question.question}
|
||||
onChange={(e) => handleQuestionChange(actualIndex, "question", e.target.value)}
|
||||
placeholder="Enter the question..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select
|
||||
value={question.category}
|
||||
onValueChange={(value) => handleQuestionChange(actualIndex, "category", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formData.categories.map(category => (
|
||||
<SelectItem key={category} value={category}>{category}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Answer</Label>
|
||||
<Textarea
|
||||
value={question.answer}
|
||||
onChange={(e) => handleQuestionChange(actualIndex, "answer", e.target.value)}
|
||||
placeholder="Enter the detailed answer..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center pt-6 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{showPreview ? "Hide" : "Show"} Preview
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateFaqMutation.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updateFaqMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Updated Info */}
|
||||
{currentFaq && (
|
||||
<div className="text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
Last updated: {format(new Date(currentFaq.lastUpdated), "PPpp")}
|
||||
</span>
|
||||
<span>
|
||||
Updated by: {currentFaq.updatedBy}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>FAQ Page Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Introduction */}
|
||||
{formData.introduction && (
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Frequently Asked Questions</h2>
|
||||
<p className="text-muted-foreground">{formData.introduction}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{formData.categories.map(category => (
|
||||
<Badge key={category} variant="outline">{category}</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Questions by Category */}
|
||||
{formData.categories.map(category => {
|
||||
const categoryQuestions = formData.questions
|
||||
.filter(q => q.category === category && q.isActive)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
if (categoryQuestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={category} className="space-y-4">
|
||||
<h3 className="text-xl font-semibold border-b pb-2">{category}</h3>
|
||||
<div className="space-y-3">
|
||||
{categoryQuestions.map((q, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<h4 className="font-medium mb-2">{q.question}</h4>
|
||||
<p className="text-muted-foreground text-sm">{q.answer}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Contact Info */}
|
||||
{(formData.contactInfo.email || formData.contactInfo.phone) && (
|
||||
<div className="text-center p-4 bg-muted rounded-lg">
|
||||
<h4 className="font-medium mb-2">Still have questions?</h4>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{formData.contactInfo.email && (
|
||||
<div>Email us at: {formData.contactInfo.email}</div>
|
||||
)}
|
||||
{formData.contactInfo.phone && (
|
||||
<div>Call us at: {formData.contactInfo.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/app/mgmt/dashboard/configurations/page.tsx
Normal file
104
src/app/mgmt/dashboard/configurations/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import AdPricingConfig from "./ad-pricing/page";
|
||||
import PrivacyPolicyConfig from "./privacy-policy/page";
|
||||
import SearchSloganConfig from "./search-slogan/page";
|
||||
import AboutUsConfig from "./about-us/page";
|
||||
import FaqConfig from "./faq/page";
|
||||
import ContactPageConfig from "./contact-page/page";
|
||||
import TermsConditionsConfig from "./tc/page";
|
||||
import {
|
||||
DollarSign,
|
||||
Shield,
|
||||
Search,
|
||||
Users,
|
||||
HelpCircle,
|
||||
Phone,
|
||||
FileText,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
|
||||
export default function ConfigurationsPage() {
|
||||
const [activeTab, setActiveTab] = useState("ad-pricing");
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">⚙️ System Configurations</h1>
|
||||
</div>
|
||||
|
||||
{/* Configuration Categories Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-7 mb-6">
|
||||
<TabsTrigger value="ad-pricing" className="flex items-center gap-2 text-xs">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Ad Pricing
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="privacy-policy" className="flex items-center gap-2 text-xs">
|
||||
<Shield className="h-4 w-4" />
|
||||
Privacy Policy
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="search-slogan" className="flex items-center gap-2 text-xs">
|
||||
<Search className="h-4 w-4" />
|
||||
Search Slogan
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="about-us" className="flex items-center gap-2 text-xs">
|
||||
<Users className="h-4 w-4" />
|
||||
About Us
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="faq" className="flex items-center gap-2 text-xs">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
FAQ
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="contact-page" className="flex items-center gap-2 text-xs">
|
||||
<Phone className="h-4 w-4" />
|
||||
Contact Page
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terms-conditions" className="flex items-center gap-2 text-xs">
|
||||
<FileText className="h-4 w-4" />
|
||||
Terms & Conditions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Ad Pricing Configuration */}
|
||||
<TabsContent value="ad-pricing">
|
||||
<AdPricingConfig />
|
||||
</TabsContent>
|
||||
|
||||
{/* Privacy Policy Configuration */}
|
||||
<TabsContent value="privacy-policy">
|
||||
<PrivacyPolicyConfig />
|
||||
</TabsContent>
|
||||
|
||||
{/* Search Slogan Configuration */}
|
||||
<TabsContent value="search-slogan">
|
||||
<SearchSloganConfig />
|
||||
</TabsContent>
|
||||
|
||||
{/* About Us Configuration */}
|
||||
<TabsContent value="about-us">
|
||||
<AboutUsConfig />
|
||||
</TabsContent>
|
||||
|
||||
{/* FAQ Configuration */}
|
||||
<TabsContent value="faq">
|
||||
<FaqConfig />
|
||||
</TabsContent>
|
||||
|
||||
{/* Contact Page Configuration */}
|
||||
<TabsContent value="contact-page">
|
||||
<ContactPageConfig />
|
||||
</TabsContent>
|
||||
|
||||
{/* Terms and Conditions Configuration */}
|
||||
<TabsContent value="terms-conditions">
|
||||
<TermsConditionsConfig />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
368
src/app/mgmt/dashboard/configurations/privacy-policy/page.tsx
Normal file
368
src/app/mgmt/dashboard/configurations/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Shield, Save, History, Calendar, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import api from "@/lib/api";
|
||||
|
||||
// TipTap Editor
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
|
||||
// Editor Components (reuse from TC)
|
||||
import { EditorToolbar } from "../tc/editor-toolbar";
|
||||
import "../tc/editor.css";
|
||||
|
||||
interface PrivacyPolicy {
|
||||
_id?: string;
|
||||
content: string;
|
||||
version: string;
|
||||
effectiveDate: Date;
|
||||
isActive: boolean;
|
||||
lastUpdated: Date;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
interface PrivacyPolicyForm {
|
||||
content: string;
|
||||
version: string;
|
||||
effectiveDate: string;
|
||||
}
|
||||
|
||||
export default function PrivacyPolicyConfig() {
|
||||
const [formData, setFormData] = useState<PrivacyPolicyForm>({
|
||||
content: "",
|
||||
version: "1.0",
|
||||
effectiveDate: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Get current privacy policy
|
||||
const { data: currentPolicy, isLoading, error } = useQuery({
|
||||
queryKey: ["privacy-policy"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/privacy-policy");
|
||||
return data as PrivacyPolicy;
|
||||
}
|
||||
});
|
||||
|
||||
// Get privacy policy history
|
||||
const { data: policyHistory, isLoading: historyLoading } = useQuery({
|
||||
queryKey: ["privacy-policy-history"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/privacy-policy/history");
|
||||
return data as PrivacyPolicy[];
|
||||
},
|
||||
enabled: showHistory
|
||||
});
|
||||
|
||||
// Update privacy policy mutation
|
||||
const updatePolicyMutation = useMutation({
|
||||
mutationFn: async (policyData: PrivacyPolicyForm) => {
|
||||
const { data } = await api.post("/configurations/privacy-policy", policyData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Privacy policy updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["privacy-policy"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["privacy-policy-history"] });
|
||||
setHasChanges(false);
|
||||
setLastSaved(new Date());
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Failed to update privacy policy");
|
||||
}
|
||||
});
|
||||
|
||||
// Debounced auto-save function
|
||||
const debouncedSave = useCallback(
|
||||
(content: string) => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
if (content !== currentPolicy?.content && content.trim().length > 0) {
|
||||
const updatedFormData = { ...formData, content };
|
||||
updatePolicyMutation.mutate(updatedFormData);
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
[formData, currentPolicy?.content, updatePolicyMutation]
|
||||
);
|
||||
|
||||
// Initialize editor
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: "text-blue-600 underline hover:text-blue-800",
|
||||
},
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: 20000,
|
||||
}),
|
||||
],
|
||||
content: "",
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.getHTML();
|
||||
setFormData(prev => ({ ...prev, content: html }));
|
||||
setHasChanges(true);
|
||||
debouncedSave(html);
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when current policy loads
|
||||
useEffect(() => {
|
||||
if (currentPolicy) {
|
||||
const effectiveDate = new Date(currentPolicy.effectiveDate).toISOString().split('T')[0];
|
||||
setFormData({
|
||||
content: currentPolicy.content,
|
||||
version: currentPolicy.version,
|
||||
effectiveDate
|
||||
});
|
||||
|
||||
if (editor && !editor.isDestroyed) {
|
||||
editor.commands.setContent(currentPolicy.content);
|
||||
}
|
||||
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [currentPolicy, editor]);
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFormChange = (field: keyof PrivacyPolicyForm, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (editor) {
|
||||
const content = editor.getHTML();
|
||||
const updatedFormData = { ...formData, content };
|
||||
updatePolicyMutation.mutate(updatedFormData);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load privacy policy configuration. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Policy Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Privacy Policy Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-10" />
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
<Skeleton className="h-64" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Policy Metadata */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">Version</Label>
|
||||
<Input
|
||||
id="version"
|
||||
value={formData.version}
|
||||
onChange={(e) => handleFormChange("version", e.target.value)}
|
||||
placeholder="e.g., 1.0, 2.1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="effectiveDate">Effective Date</Label>
|
||||
<Input
|
||||
id="effectiveDate"
|
||||
type="date"
|
||||
value={formData.effectiveDate}
|
||||
onChange={(e) => handleFormChange("effectiveDate", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Editor */}
|
||||
<div className="space-y-2">
|
||||
<Label>Privacy Policy Content</Label>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
{/* Editor Toolbar */}
|
||||
<EditorToolbar editor={editor} />
|
||||
|
||||
{/* Editor Content */}
|
||||
<div className="bg-white min-h-[400px]">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
{/* Editor Footer */}
|
||||
<div className="flex justify-between text-xs text-muted-foreground border-t px-3 py-2">
|
||||
<div>
|
||||
{editor && (
|
||||
<>
|
||||
{editor.storage.characterCount.characters()} characters
|
||||
·
|
||||
{editor.storage.characterCount.words()} words
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{updatePolicyMutation.isPending && (
|
||||
<span className="text-blue-600">Saving...</span>
|
||||
)}
|
||||
{lastSaved && (
|
||||
<span>Last saved: {lastSaved.toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
{showHistory ? "Hide" : "Show"} History
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updatePolicyMutation.isPending}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updatePolicyMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Policy Info */}
|
||||
{currentPolicy && (
|
||||
<div className="text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<span className="font-medium">Current Version:</span> {currentPolicy.version}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Effective Date:</span> {format(new Date(currentPolicy.effectiveDate), "PP")}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Last Updated:</span> {format(new Date(currentPolicy.lastUpdated), "PP")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Policy History */}
|
||||
{showHistory && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Privacy Policy History
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{historyLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : policyHistory && policyHistory.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{policyHistory.map((policy, index) => (
|
||||
<div key={policy._id || index} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={index === 0 ? "default" : "secondary"}>
|
||||
{index === 0 ? "Current" : `v${policy.version}`}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Effective: {format(new Date(policy.effectiveDate), "PP")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Updated: {format(new Date(policy.lastUpdated), "PPpp")} by {policy.updatedBy}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-3">
|
||||
<div dangerouslySetInnerHTML={{ __html: policy.content.substring(0, 300) + "..." }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No privacy policy history available
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
src/app/mgmt/dashboard/configurations/search-slogan/page.tsx
Normal file
239
src/app/mgmt/dashboard/configurations/search-slogan/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, Save, Eye, AlertCircle, Type } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { format } from "date-fns";
|
||||
import api from "@/lib/api";
|
||||
|
||||
interface SearchSlogan {
|
||||
_id?: string;
|
||||
primarySlogan: string;
|
||||
secondarySlogan?: string;
|
||||
isActive: boolean;
|
||||
lastUpdated: Date;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
interface SearchSloganForm {
|
||||
primarySlogan: string;
|
||||
secondarySlogan: string;
|
||||
}
|
||||
|
||||
export default function SearchSloganConfig() {
|
||||
const [formData, setFormData] = useState<SearchSloganForm>({
|
||||
primarySlogan: "",
|
||||
secondarySlogan: ""
|
||||
});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get current search slogan
|
||||
const { data: currentSlogan, isLoading, error } = useQuery({
|
||||
queryKey: ["search-slogan"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/search-slogan");
|
||||
return data as SearchSlogan;
|
||||
}
|
||||
});
|
||||
|
||||
// Update search slogan mutation
|
||||
const updateSloganMutation = useMutation({
|
||||
mutationFn: async (sloganData: SearchSloganForm) => {
|
||||
const { data } = await api.post("/configurations/search-slogan", sloganData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Search slogan updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["search-slogan"] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || "Failed to update search slogan");
|
||||
}
|
||||
});
|
||||
|
||||
// Update form when current slogan loads
|
||||
useEffect(() => {
|
||||
if (currentSlogan) {
|
||||
setFormData({
|
||||
primarySlogan: currentSlogan.primarySlogan,
|
||||
secondarySlogan: currentSlogan.secondarySlogan || ""
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [currentSlogan]);
|
||||
|
||||
const handleFormChange = (field: keyof SearchSloganForm, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateSloganMutation.mutate(formData);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load search slogan configuration. Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Slogan Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
Search Page Slogan Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10" />
|
||||
<Skeleton className="h-20" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Current Slogan Display */}
|
||||
{currentSlogan && (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold text-blue-900">
|
||||
{currentSlogan.primarySlogan}
|
||||
</h2>
|
||||
{currentSlogan.secondarySlogan && (
|
||||
<p className="text-blue-700 text-lg">
|
||||
{currentSlogan.secondarySlogan}
|
||||
</p>
|
||||
)}
|
||||
<Badge variant="secondary" className="mt-2">
|
||||
Current Active Slogan
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Form */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="primarySlogan" className="flex items-center gap-2">
|
||||
<Type className="h-4 w-4" />
|
||||
Primary Slogan
|
||||
</Label>
|
||||
<Input
|
||||
id="primarySlogan"
|
||||
value={formData.primarySlogan}
|
||||
onChange={(e) => handleFormChange("primarySlogan", e.target.value)}
|
||||
placeholder="Enter primary slogan (e.g., 'Find Your Perfect Ad Solution')"
|
||||
maxLength={100}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formData.primarySlogan.length}/100 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="secondarySlogan">
|
||||
Secondary Slogan (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="secondarySlogan"
|
||||
value={formData.secondarySlogan}
|
||||
onChange={(e) => handleFormChange("secondarySlogan", e.target.value)}
|
||||
placeholder="Enter secondary slogan for additional context (e.g., 'Browse thousands of verified listings')"
|
||||
maxLength={200}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formData.secondarySlogan.length}/200 characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{showPreview && (
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 border rounded-lg p-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{formData.primarySlogan || "Your Primary Slogan"}
|
||||
</h2>
|
||||
{formData.secondarySlogan && (
|
||||
<p className="text-gray-700 text-lg">
|
||||
{formData.secondarySlogan}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{showPreview ? "Hide" : "Show"} Preview
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateSloganMutation.isPending || !formData.primarySlogan.trim()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{updateSloganMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Slogan Info */}
|
||||
{currentSlogan && (
|
||||
<div className="text-sm text-muted-foreground border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
Last updated: {format(new Date(currentSlogan.lastUpdated), "PPpp")}
|
||||
</span>
|
||||
<span>
|
||||
Updated by: {currentSlogan.updatedBy}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/app/mgmt/dashboard/configurations/tc/editor-toolbar.tsx
Normal file
178
src/app/mgmt/dashboard/configurations/tc/editor-toolbar.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
UnderlineIcon,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
LinkIcon,
|
||||
Quote,
|
||||
Code,
|
||||
Minus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
interface EditorToolbarProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
export function EditorToolbar({ editor }: EditorToolbarProps) {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const MenuButton = ({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
isActive = false,
|
||||
title,
|
||||
}: {
|
||||
icon: any;
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`p-1 h-8 ${isActive ? "bg-muted text-primary" : ""}`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={!editor || editor.isDestroyed}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 p-2 border-b bg-muted/50">
|
||||
<MenuButton
|
||||
icon={Bold}
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
isActive={editor?.isActive("bold")}
|
||||
title="Bold"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Italic}
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
isActive={editor?.isActive("italic")}
|
||||
title="Italic"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={UnderlineIcon}
|
||||
onClick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
isActive={editor?.isActive("underline")}
|
||||
title="Underline"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Strikethrough}
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
isActive={editor?.isActive("strike")}
|
||||
title="Strikethrough"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={Heading1}
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
isActive={editor?.isActive("heading", { level: 1 })}
|
||||
title="Heading 1"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Heading2}
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
isActive={editor?.isActive("heading", { level: 2 })}
|
||||
title="Heading 2"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Heading3}
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
isActive={editor?.isActive("heading", { level: 3 })}
|
||||
title="Heading 3"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={AlignLeft}
|
||||
onClick={() => editor?.chain().focus().setTextAlign("left").run()}
|
||||
isActive={editor?.isActive({ textAlign: "left" })}
|
||||
title="Align Left"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignCenter}
|
||||
onClick={() => editor?.chain().focus().setTextAlign("center").run()}
|
||||
isActive={editor?.isActive({ textAlign: "center" })}
|
||||
title="Align Center"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignRight}
|
||||
onClick={() => editor?.chain().focus().setTextAlign("right").run()}
|
||||
isActive={editor?.isActive({ textAlign: "right" })}
|
||||
title="Align Right"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignJustify}
|
||||
onClick={() => editor?.chain().focus().setTextAlign("justify").run()}
|
||||
isActive={editor?.isActive({ textAlign: "justify" })}
|
||||
title="Justify"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={List}
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor?.isActive("bulletList")}
|
||||
title="Bullet List"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={ListOrdered}
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor?.isActive("orderedList")}
|
||||
title="Ordered List"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={LinkIcon}
|
||||
onClick={() => {
|
||||
const url = window.prompt("URL");
|
||||
if (url) {
|
||||
editor?.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}}
|
||||
isActive={editor?.isActive("link")}
|
||||
title="Add Link"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Quote}
|
||||
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor?.isActive("blockquote")}
|
||||
title="Blockquote"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Code}
|
||||
onClick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
isActive={editor?.isActive("codeBlock")}
|
||||
title="Code Block"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Minus}
|
||||
onClick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={X}
|
||||
onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}
|
||||
title="Clear Formatting"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/app/mgmt/dashboard/configurations/tc/editor.css
Normal file
141
src/app/mgmt/dashboard/configurations/tc/editor.css
Normal file
@@ -0,0 +1,141 @@
|
||||
/* TipTap Editor Styles */
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
padding: 1rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.ProseMirror h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 1.5rem 0 1rem 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.ProseMirror h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 1.25rem 0 0.75rem 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.ProseMirror h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin: 0.75rem 0;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.ProseMirror strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ProseMirror em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ProseMirror u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ProseMirror s {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.ProseMirror ul {
|
||||
list-style-type: disc;
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.ProseMirror ol {
|
||||
list-style-type: decimal;
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
margin: 0.25rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-style: italic;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.ProseMirror code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas,
|
||||
"Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.ProseMirror pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ProseMirror a:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.ProseMirror hr {
|
||||
border: none;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.ProseMirror[style*="text-align: center"] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ProseMirror[style*="text-align: right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ProseMirror[style*="text-align: justify"] {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Placeholder styles */
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
@@ -1,391 +1,7 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "@/lib/api";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
UnderlineIcon,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
LinkIcon,
|
||||
Quote,
|
||||
Code,
|
||||
Minus,
|
||||
X,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
|
||||
// Import TipTap editor
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import TermsAndConditionsPage from "./terms-conditions";
|
||||
|
||||
// CSS for the editor
|
||||
// import "./tiptap.css"
|
||||
|
||||
export default function TermsAndConditionsPage() {
|
||||
const [content, setContent] = useState("");
|
||||
const [autoSaveTimer, setAutoSaveTimer] = useState<NodeJS.Timeout | null>(
|
||||
null
|
||||
);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current terms and conditions
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["terms-and-conditions"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/terms-and-conditions");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Update terms and conditions
|
||||
const { mutate: update, isPending } = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const { data } = await api.post("/configurations/terms-and-conditions", {
|
||||
content,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Terms and conditions updated successfully");
|
||||
setLastSaved(new Date());
|
||||
queryClient.invalidateQueries({ queryKey: ["terms-and-conditions"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to update terms and conditions");
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize editor
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: 10000,
|
||||
}),
|
||||
],
|
||||
content: "",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm sm:prose lg:prose-lg xl:prose-xl focus:outline-none",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.getHTML();
|
||||
setContent(html);
|
||||
},
|
||||
});
|
||||
|
||||
// Update editor content when data is loaded
|
||||
useEffect(() => {
|
||||
if (data && editor && !editor.isDestroyed) {
|
||||
editor.commands.setContent(data.content || "");
|
||||
setContent(data.content || "");
|
||||
}
|
||||
}, [data, editor]);
|
||||
|
||||
// Handle auto-save with debounce
|
||||
useEffect(() => {
|
||||
if (content && content !== data?.content) {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
update(content);
|
||||
}, 5000);
|
||||
|
||||
setAutoSaveTimer(timer);
|
||||
}
|
||||
}, [content, data?.content]);
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
}
|
||||
};
|
||||
}, [autoSaveTimer]);
|
||||
|
||||
// Handle manual save
|
||||
const handleSave = () => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
setAutoSaveTimer(null);
|
||||
}
|
||||
update(content);
|
||||
};
|
||||
|
||||
// Editor toolbar button component
|
||||
// @ts-ignore
|
||||
const MenuButton = ({ icon: Icon, onClick, isActive = false, title }) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`p-1 h-8 ${isActive ? "bg-muted text-primary" : ""}`}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Terms and Conditions</h1>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Failed to load terms and conditions
|
||||
</p>
|
||||
<Button onClick={() => refetch()}>Try Again</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Terms and Conditions</h1>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isPending || isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Edit Terms and Conditions</CardTitle>
|
||||
<CardDescription>
|
||||
Update the terms and conditions that will be displayed to users.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-8 w-5/6" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
{/* Editor Toolbar */}
|
||||
<div className="flex flex-wrap gap-1 p-2 border-b bg-muted/50">
|
||||
<MenuButton
|
||||
icon={Bold}
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
isActive={editor?.isActive("bold")}
|
||||
title="Bold"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Italic}
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
isActive={editor?.isActive("italic")}
|
||||
title="Italic"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={UnderlineIcon}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleUnderline().run()
|
||||
}
|
||||
isActive={editor?.isActive("underline")}
|
||||
title="Underline"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Strikethrough}
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
isActive={editor?.isActive("strike")}
|
||||
title="Strikethrough"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={Heading1}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
isActive={editor?.isActive("heading", { level: 1 })}
|
||||
title="Heading 1"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Heading2}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
isActive={editor?.isActive("heading", { level: 2 })}
|
||||
title="Heading 2"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Heading3}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
isActive={editor?.isActive("heading", { level: 3 })}
|
||||
title="Heading 3"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={AlignLeft}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setTextAlign("left").run()
|
||||
}
|
||||
isActive={editor?.isActive({ textAlign: "left" })}
|
||||
title="Align Left"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignCenter}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setTextAlign("center").run()
|
||||
}
|
||||
isActive={editor?.isActive({ textAlign: "center" })}
|
||||
title="Align Center"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignRight}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setTextAlign("right").run()
|
||||
}
|
||||
isActive={editor?.isActive({ textAlign: "right" })}
|
||||
title="Align Right"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignJustify}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setTextAlign("justify").run()
|
||||
}
|
||||
isActive={editor?.isActive({ textAlign: "justify" })}
|
||||
title="Justify"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={List}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
isActive={editor?.isActive("bulletList")}
|
||||
title="Bullet List"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={ListOrdered}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
isActive={editor?.isActive("orderedList")}
|
||||
title="Ordered List"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={LinkIcon}
|
||||
onClick={() => {
|
||||
const url = window.prompt("URL");
|
||||
if (url) {
|
||||
editor?.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}}
|
||||
isActive={editor?.isActive("link")}
|
||||
title="Add Link"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Quote}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
isActive={editor?.isActive("blockquote")}
|
||||
title="Blockquote"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Code}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleCodeBlock().run()
|
||||
}
|
||||
isActive={editor?.isActive("codeBlock")}
|
||||
title="Code Block"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Minus}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setHorizontalRule().run()
|
||||
}
|
||||
title="Horizontal Rule"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={X}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().unsetAllMarks().clearNodes().run()
|
||||
}
|
||||
title="Clear Formatting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor Content */}
|
||||
<div className="p-4 min-h-[400px]">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between text-xs text-muted-foreground border-t">
|
||||
<div>
|
||||
{editor && (
|
||||
<>
|
||||
{editor.storage.characterCount.characters()} characters
|
||||
·
|
||||
{editor.storage.characterCount.words()} words
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{lastSaved && (
|
||||
<span>Last saved: {lastSaved.toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function TermsConditionsConfig() {
|
||||
return <TermsAndConditionsPage />;
|
||||
}
|
||||
460
src/app/mgmt/dashboard/configurations/tc/terms-conditions.tsx
Normal file
460
src/app/mgmt/dashboard/configurations/tc/terms-conditions.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import api from "@/lib/api";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
UnderlineIcon,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
LinkIcon,
|
||||
Quote,
|
||||
Code,
|
||||
Minus,
|
||||
X,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
|
||||
// Import TipTap editor
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
|
||||
// Import CSS for editor styling
|
||||
import "./editor.css";
|
||||
|
||||
export default function TermsAndConditionsPage() {
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const initialContentRef = useRef<string>("");
|
||||
const lastSavedContentRef = useRef<string>("");
|
||||
const isFormattingChangeRef = useRef<boolean>(false);
|
||||
|
||||
// Fetch current terms and conditions
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["terms-and-conditions"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/configurations/terms-and-conditions");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Update terms and conditions
|
||||
const { mutate: update, isPending } = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const { data } = await api.post("/configurations/terms-and-conditions", {
|
||||
content,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, savedContent) => {
|
||||
toast.success("Terms and conditions updated successfully");
|
||||
setLastSaved(new Date());
|
||||
setHasUnsavedChanges(false);
|
||||
lastSavedContentRef.current = savedContent;
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to update terms and conditions");
|
||||
},
|
||||
});
|
||||
|
||||
// Function to extract plain text from HTML for comparison
|
||||
const getPlainText = useCallback((html: string) => {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, "") // Remove HTML tags
|
||||
.replace(/ /g, " ") // Replace non-breaking spaces
|
||||
.replace(/\s+/g, " ") // Normalize whitespace
|
||||
.trim();
|
||||
}, []);
|
||||
|
||||
// Debounced auto-save function - only save if actual text content changed
|
||||
const debouncedSave = useCallback(
|
||||
(content: string) => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
|
||||
autoSaveTimerRef.current = setTimeout(() => {
|
||||
// Skip saving if this was just a formatting change
|
||||
if (isFormattingChangeRef.current) {
|
||||
isFormattingChangeRef.current = false;
|
||||
setHasUnsavedChanges(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentText = getPlainText(content);
|
||||
const lastSavedText = getPlainText(lastSavedContentRef.current);
|
||||
|
||||
// Only save if the actual text content has changed
|
||||
if (currentText !== lastSavedText && currentText.length > 0) {
|
||||
update(content);
|
||||
} else {
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, 3000); // Increased to 3 seconds to avoid aggressive saving
|
||||
},
|
||||
[update, getPlainText]
|
||||
);
|
||||
|
||||
// Initialize editor
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
}),
|
||||
Underline,
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: "text-blue-600 underline hover:text-blue-800",
|
||||
},
|
||||
}),
|
||||
CharacterCount.configure({
|
||||
limit: 10000,
|
||||
}),
|
||||
],
|
||||
content: "",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "focus:outline-none",
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const html = editor.getHTML();
|
||||
const currentText = getPlainText(html);
|
||||
const lastSavedText = getPlainText(lastSavedContentRef.current);
|
||||
|
||||
// Check if only the text content changed (not just formatting)
|
||||
const hasTextChanged = currentText !== lastSavedText;
|
||||
setHasUnsavedChanges(hasTextChanged);
|
||||
|
||||
// Only trigger auto-save if text content actually changed
|
||||
if (hasTextChanged) {
|
||||
debouncedSave(html);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Update editor content when data is loaded
|
||||
useEffect(() => {
|
||||
if (data && editor && !editor.isDestroyed) {
|
||||
const content = data.content || "";
|
||||
initialContentRef.current = content;
|
||||
lastSavedContentRef.current = content;
|
||||
editor.commands.setContent(content);
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, [data, editor]);
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle manual save
|
||||
const handleSave = useCallback(() => {
|
||||
if (editor && !editor.isDestroyed) {
|
||||
const content = editor.getHTML();
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
update(content);
|
||||
}
|
||||
}, [editor, update]);
|
||||
|
||||
// Handle formatting button clicks - mark as formatting change
|
||||
const handleFormattingClick = useCallback((action: () => void) => {
|
||||
isFormattingChangeRef.current = true;
|
||||
action();
|
||||
}, []);
|
||||
|
||||
// Editor toolbar button component
|
||||
const MenuButton = ({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
isActive = false,
|
||||
title,
|
||||
}: {
|
||||
icon: any;
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`p-1 h-8 ${isActive ? "bg-muted text-primary" : ""}`}
|
||||
onClick={() => handleFormattingClick(onClick)}
|
||||
title={title}
|
||||
disabled={!editor || editor.isDestroyed}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">Terms and Conditions</h1>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Failed to load terms and conditions
|
||||
</p>
|
||||
<Button onClick={() => refetch()}>Try Again</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Terms and Conditions</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isPending || isLoading || !hasUnsavedChanges}
|
||||
className="gap-2"
|
||||
variant={hasUnsavedChanges ? "default" : "outline"}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Edit Terms and Conditions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-8 w-5/6" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
{/* Editor Toolbar */}
|
||||
<div className="flex flex-wrap gap-1 p-2 border-b bg-muted/50">
|
||||
<MenuButton
|
||||
icon={Bold}
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
isActive={editor?.isActive("bold")}
|
||||
title="Bold"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Italic}
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
isActive={editor?.isActive("italic")}
|
||||
title="Italic"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={UnderlineIcon}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleUnderline().run()
|
||||
}
|
||||
isActive={editor?.isActive("underline")}
|
||||
title="Underline"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Strikethrough}
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
isActive={editor?.isActive("strike")}
|
||||
title="Strikethrough"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={Heading1}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
isActive={editor?.isActive("heading", { level: 1 })}
|
||||
title="Heading 1"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Heading2}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
isActive={editor?.isActive("heading", { level: 2 })}
|
||||
title="Heading 2"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Heading3}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
isActive={editor?.isActive("heading", { level: 3 })}
|
||||
title="Heading 3"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={AlignLeft}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setTextAlign("left").run()
|
||||
}
|
||||
isActive={editor?.isActive({ textAlign: "left" })}
|
||||
title="Align Left"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignCenter}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setTextAlign("center").run()
|
||||
}
|
||||
isActive={editor?.isActive({ textAlign: "center" })}
|
||||
title="Align Center"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignRight}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setTextAlign("right").run()
|
||||
}
|
||||
isActive={editor?.isActive({ textAlign: "right" })}
|
||||
title="Align Right"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={AlignJustify}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setTextAlign("justify").run()
|
||||
}
|
||||
isActive={editor?.isActive({ textAlign: "justify" })}
|
||||
title="Justify"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={List}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
isActive={editor?.isActive("bulletList")}
|
||||
title="Bullet List"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={ListOrdered}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
isActive={editor?.isActive("orderedList")}
|
||||
title="Ordered List"
|
||||
/>
|
||||
<div className="w-px h-6 bg-border mx-1 self-center" />
|
||||
<MenuButton
|
||||
icon={LinkIcon}
|
||||
onClick={() => {
|
||||
const url = window.prompt("URL");
|
||||
if (url) {
|
||||
editor?.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}}
|
||||
isActive={editor?.isActive("link")}
|
||||
title="Add Link"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Quote}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
isActive={editor?.isActive("blockquote")}
|
||||
title="Blockquote"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Code}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleCodeBlock().run()
|
||||
}
|
||||
isActive={editor?.isActive("codeBlock")}
|
||||
title="Code Block"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={Minus}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().setHorizontalRule().run()
|
||||
}
|
||||
title="Horizontal Rule"
|
||||
/>
|
||||
<MenuButton
|
||||
icon={X}
|
||||
onClick={() =>
|
||||
editor?.chain().focus().unsetAllMarks().clearNodes().run()
|
||||
}
|
||||
title="Clear Formatting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor Content */}
|
||||
<div className="bg-white">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between text-xs text-muted-foreground border-t">
|
||||
<div>
|
||||
{editor && (
|
||||
<>
|
||||
{editor.storage.characterCount.characters()} characters
|
||||
·
|
||||
{editor.storage.characterCount.words()} words
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isPending && <span className="text-blue-600">Saving...</span>}
|
||||
{lastSaved && (
|
||||
<span>Last saved: {lastSaved.toLocaleTimeString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,53 @@
|
||||
"use client";
|
||||
import { ManagementLayout } from "@/components/mgmt/management-layout";
|
||||
import api from "@/lib/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// Create a context for dashboard data
|
||||
export const DashboardContext = createContext<any>(null);
|
||||
|
||||
export function useDashboardData() {
|
||||
return useContext(DashboardContext);
|
||||
}
|
||||
|
||||
export default function ManagementDashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <ManagementLayout>{children}</ManagementLayout>;
|
||||
// Fetch global dashboard data
|
||||
const {
|
||||
data: dashboardData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["globalDashboard"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/ad-dashboard/global");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
Loading dashboard...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen text-red-500">
|
||||
Failed to load dashboard data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContext.Provider value={dashboardData}>
|
||||
<ManagementLayout>{children}</ManagementLayout>
|
||||
</DashboardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,52 +2,89 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { BarChart3, FileText, Users } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { DashboardStats } from "@/lib/types/dashboard-stats";
|
||||
import {
|
||||
BarChart3,
|
||||
FileText,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Eye,
|
||||
Clock,
|
||||
Image,
|
||||
Video,
|
||||
List,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { useDashboardData } from "./layout";
|
||||
import { StatusDistributionChart } from "@/components/mgmt/stat-distribution-chart";
|
||||
import { RecentAdsList } from "@/components/mgmt/recent-ad-list";
|
||||
|
||||
export default function ManagementDashboardPage() {
|
||||
const { data: dashboardStats, isLoading } = useQuery<DashboardStats>({
|
||||
queryKey: ["globalDashboardStats"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/ad-dashboard/global");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
const dashboardStats = useDashboardData();
|
||||
const isLoading = !dashboardStats;
|
||||
|
||||
// Calculate the IN REVIEW count (FOR_REVIEW + YET_TO_BE_PUBLISHED)
|
||||
const inReviewCount =
|
||||
(dashboardStats?.statusCounts.FOR_REVIEW || 0) +
|
||||
(dashboardStats?.statusCounts.YET_TO_BE_PUBLISHED || 0);
|
||||
|
||||
// Define the tiles to display
|
||||
const tiles = [
|
||||
// Define the main overview tiles
|
||||
const overviewTiles = [
|
||||
{
|
||||
label: "Total Ads",
|
||||
count: dashboardStats?.totalAds || 0,
|
||||
icon: <BarChart3 className="h-4 w-4 text-muted-foreground" />,
|
||||
icon: <BarChart3 className="h-6 w-6" />,
|
||||
description: "All advertisements",
|
||||
color: "bg-blue-50 text-blue-600 border-blue-200",
|
||||
iconBg: "bg-blue-100",
|
||||
},
|
||||
{
|
||||
label: "Published",
|
||||
count: dashboardStats?.statusCounts.PUBLISHED || 0,
|
||||
icon: <FileText className="h-4 w-4 text-muted-foreground" />,
|
||||
icon: <CheckCircle2 className="h-6 w-6" />,
|
||||
description: "Live advertisements",
|
||||
color: "bg-green-50 text-green-600 border-green-200",
|
||||
iconBg: "bg-green-100",
|
||||
},
|
||||
{
|
||||
label: "In Review",
|
||||
count: inReviewCount,
|
||||
icon: <FileText className="h-4 w-4 text-muted-foreground" />,
|
||||
icon: <Clock className="h-6 w-6" />,
|
||||
description: "Pending approval",
|
||||
color: "bg-orange-50 text-orange-600 border-orange-200",
|
||||
iconBg: "bg-orange-100",
|
||||
},
|
||||
{
|
||||
label: "Active Users",
|
||||
count: 1234, // This would come from a different API endpoint
|
||||
icon: <Users className="h-4 w-4 text-muted-foreground" />,
|
||||
description: "Registered users",
|
||||
label: "Draft",
|
||||
count: dashboardStats?.statusCounts.DRAFT || 0,
|
||||
icon: <FileText className="h-6 w-6" />,
|
||||
description: "Draft advertisements",
|
||||
color: "bg-gray-50 text-gray-600 border-gray-200",
|
||||
iconBg: "bg-gray-100",
|
||||
},
|
||||
];
|
||||
|
||||
// Define ad type breakdown tiles
|
||||
const adTypeTiles = [
|
||||
{
|
||||
label: "Line Ads",
|
||||
count: dashboardStats?.lineAds || 0,
|
||||
icon: <List className="h-5 w-5" />,
|
||||
description: "Text-based ads",
|
||||
color: "bg-purple-50 text-purple-600 border-purple-200",
|
||||
},
|
||||
{
|
||||
label: "Poster Ads",
|
||||
count: dashboardStats?.posterAds || 0,
|
||||
icon: <Image className="h-5 w-5" />,
|
||||
description: "Image advertisements",
|
||||
color: "bg-pink-50 text-pink-600 border-pink-200",
|
||||
},
|
||||
{
|
||||
label: "Video Ads",
|
||||
count: dashboardStats?.videoAds || 0,
|
||||
icon: <Video className="h-5 w-5" />,
|
||||
description: "Video advertisements",
|
||||
color: "bg-indigo-50 text-indigo-600 border-indigo-200",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -56,37 +93,129 @@ export default function ManagementDashboardPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">Dashboard Overview</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{tiles.map((tile) => (
|
||||
<Card key={tile.label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{tile.label}
|
||||
</CardTitle>
|
||||
{tile.icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{tile.count}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tile.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="space-y-8 p-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-gray-900">
|
||||
Dashboard Overview
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Main Overview Cards */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Overview</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{overviewTiles.map((tile) => (
|
||||
<Card
|
||||
key={tile.label}
|
||||
className={`border-2 hover:shadow-lg transition-all duration-200 hover:-translate-y-1 ${tile.color}`}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium opacity-80">
|
||||
{tile.label}
|
||||
</p>
|
||||
<p className="text-3xl font-bold">
|
||||
{tile.count.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs opacity-70">{tile.description}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${tile.iconBg}`}>
|
||||
{tile.icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ad Types Breakdown */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Ad Types</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{adTypeTiles.map((tile) => (
|
||||
<Card
|
||||
key={tile.label}
|
||||
className={`border-2 hover:shadow-md transition-all duration-200 ${tile.color}`}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">{tile.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium opacity-80">
|
||||
{tile.label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{tile.count.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs opacity-70 truncate">
|
||||
{tile.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts and Additional Info */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{dashboardStats && (
|
||||
<>
|
||||
<StatusDistributionChart
|
||||
statusCounts={dashboardStats.statusCounts}
|
||||
/>
|
||||
<RecentAdsList ads={dashboardStats.ads} />
|
||||
</>
|
||||
<Card className="border-2 border-gray-200 shadow-lg">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl font-semibold text-gray-800 flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-blue-600" />
|
||||
Ad Status Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusDistributionChart
|
||||
statusCounts={dashboardStats.statusCounts}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Placeholder for future widgets */}
|
||||
<Card className="border-2 border-gray-200 shadow-lg">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-xl font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-green-600" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
<p className="font-medium mb-2">Recent Activity</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<span>Total ads created today</span>
|
||||
<span className="font-semibold text-blue-600">
|
||||
{dashboardStats?.ads?.filter((ad: any) => {
|
||||
const today = new Date().toDateString();
|
||||
return new Date(ad.created_at).toDateString() === today;
|
||||
}).length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<span>Ads pending review</span>
|
||||
<span className="font-semibold text-orange-600">
|
||||
{inReviewCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<span>Published this week</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{dashboardStats?.statusCounts.PUBLISHED || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -94,48 +223,69 @@ export default function ManagementDashboardPage() {
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold">Dashboard Overview</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16 mb-1" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="space-y-8 p-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-80" />
|
||||
<Skeleton className="h-6 w-96" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-7 w-32" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="border-2">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-12 rounded-lg" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="border-2">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="h-5 w-5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<Card className="border-2">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Card className="border-2">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-36" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start space-x-4">
|
||||
<Skeleton className="w-12 h-12 rounded-md" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-5 w-full mb-1" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { User } from "@/lib/types/user";
|
||||
import type { User, Customer } from "@/lib/types/user";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { BasicInfoForm } from "@/components/profile/basic-info-form";
|
||||
@@ -11,7 +11,7 @@ import api from "@/lib/api";
|
||||
export default function ManagementProfilePage() {
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
isLoading: isUserLoading,
|
||||
error,
|
||||
} = useQuery<User>({
|
||||
queryKey: ["user"],
|
||||
@@ -21,6 +21,17 @@ export default function ManagementProfilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: customer, isLoading: isCustomerLoading } = useQuery<Customer>({
|
||||
queryKey: ["customer"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/users/customer/me");
|
||||
return data;
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const isLoading = isUserLoading || isCustomerLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto p-6">
|
||||
@@ -54,13 +65,10 @@ export default function ManagementProfilePage() {
|
||||
<div className="space-y-6 max-w-4xl mx-auto p-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold">Profile</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your account settings and preferences.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-6">
|
||||
<BasicInfoForm user={user} />
|
||||
<div className="space-y-8">
|
||||
<BasicInfoForm user={user} customer={customer} />
|
||||
<ChangePasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { format } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import type { LineAd } from "@/lib/types/lineAd";
|
||||
import { EditAdLink } from "@/components/mgmt/EditAdLink";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Popover,
|
||||
@@ -381,10 +383,10 @@ export const columns: ColumnDef<LineAd>[] = [
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/mgmt/dashboard/review-ads/line/edit/${ad.id}`}>
|
||||
<EditAdLink adId={ad.id} adType={AdType.LINE} from="published-ads">
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Link>
|
||||
</EditAdLink>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import Zoom from "react-medium-image-zoom";
|
||||
import { PaymentDetailsDialog } from "@/components/payment/payment-details-dialog";
|
||||
import { PosterAd } from "@/lib/types/posterAd";
|
||||
import { EditAdLink } from "@/components/mgmt/EditAdLink";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
export const columns: ColumnDef<PosterAd>[] = [
|
||||
{
|
||||
accessorKey: "sequenceNumber",
|
||||
@@ -301,10 +303,10 @@ export const columns: ColumnDef<PosterAd>[] = [
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/mgmt/dashboard/review-ads/poster/edit/${ad.id}`}>
|
||||
<EditAdLink adId={ad.id} adType={AdType.POSTER} from="published-ads">
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Link>
|
||||
</EditAdLink>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { VideoAd } from "@/lib/types/videoAd";
|
||||
import { EditAdLink } from "@/components/mgmt/EditAdLink";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
import { getStatusVariant } from "@/lib/utils";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
@@ -117,7 +119,7 @@ export const columns: ColumnDef<VideoAd>[] = [
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <div className="">{row.original.position.replace("_", " ")}</div>;
|
||||
return <div className="">{row.original.position.pageType}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -287,10 +289,10 @@ export const columns: ColumnDef<VideoAd>[] = [
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
|
||||
<Link href={`/mgmt/dashboard/review-ads/video/edit/${ad.id}`}>
|
||||
<EditAdLink adId={ad.id} adType={AdType.VIDEO} from="published-ads">
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Link>
|
||||
</EditAdLink>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ export const columns: ColumnDef<VideoAd>[] = [
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <div className="">{row.original.position.replace("_", " ")}</div>;
|
||||
return <div className="">{row.original.position.pageType}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
504
src/app/mgmt/dashboard/reports/admin-reports.tsx
Normal file
504
src/app/mgmt/dashboard/reports/admin-reports.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, Download, UserCheck, Clock, CheckCircle, XCircle, Pause } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import api from "@/lib/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from "recharts";
|
||||
|
||||
interface AdminReportFilters {
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
period: "daily" | "weekly" | "monthly";
|
||||
adminId?: string;
|
||||
}
|
||||
|
||||
export function AdminReports() {
|
||||
const [filters, setFilters] = useState<AdminReportFilters>({
|
||||
startDate: new Date(new Date().setMonth(new Date().getMonth() - 1)),
|
||||
endDate: new Date(),
|
||||
period: "daily"
|
||||
});
|
||||
|
||||
// Admin Activity Report
|
||||
const { data: activityData, isLoading: activityLoading } = useQuery({
|
||||
queryKey: ["admin-activity", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
params.set("period", filters.period);
|
||||
if (filters.adminId) params.set("adminId", filters.adminId);
|
||||
|
||||
const { data } = await api.get(`/reports/admin/activity?${params}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!(filters.startDate && filters.endDate)
|
||||
});
|
||||
|
||||
// Admin User-wise Activity
|
||||
const { data: userWiseData, isLoading: userWiseLoading } = useQuery({
|
||||
queryKey: ["admin-user-wise", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
|
||||
const { data } = await api.get(`/reports/admin/user-wise-activity?${params}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!(filters.startDate && filters.endDate)
|
||||
});
|
||||
|
||||
// Admin Activity by Category
|
||||
const { data: categoryData, isLoading: categoryLoading } = useQuery({
|
||||
queryKey: ["admin-category-activity", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
|
||||
const { data } = await api.get(`/reports/admin/activity-by-category?${params}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!(filters.startDate && filters.endDate)
|
||||
});
|
||||
|
||||
const handleFilterChange = (key: keyof AdminReportFilters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const exportData = async (reportType: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
params.set("format", "csv");
|
||||
params.set("reportType", reportType);
|
||||
|
||||
const response = await api.get(`/reports/export?${params}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${reportType}-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserCheck className="h-5 w-5" />
|
||||
Admin Activity Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure date ranges and admin selection for activity reports
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Start Date</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!filters.startDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.startDate ? format(filters.startDate, "PPP") : "Pick start date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.startDate || undefined}
|
||||
onSelect={(date) => handleFilterChange("startDate", date || null)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">End Date</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!filters.endDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.endDate ? format(filters.endDate, "PPP") : "Pick end date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.endDate || undefined}
|
||||
onSelect={(date) => handleFilterChange("endDate", date || null)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Period</label>
|
||||
<Select value={filters.period} onValueChange={(value: "daily" | "weekly" | "monthly") => handleFilterChange("period", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Admin</label>
|
||||
<Select value={filters.adminId || ""} onValueChange={(value) => handleFilterChange("adminId", value || undefined)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Admins" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* <SelectItem value="">All Admins</SelectItem> */}
|
||||
{userWiseData?.map((admin: any) => (
|
||||
<SelectItem key={admin.adminId} value={admin.adminId}>
|
||||
{admin.adminName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Actions</label>
|
||||
<Button
|
||||
onClick={() => exportData("admin-activity")}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Actions</CardTitle>
|
||||
<UserCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{activityData?.summary?.totalActions || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Admin actions taken
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Approvals</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{activityData?.summary?.approvals || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ads approved
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Rejections</CardTitle>
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{activityData?.summary?.rejections || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ads rejected
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Response</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{activityData?.summary?.avgTimeToAction || 0}h
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Average response time
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Admin Activity Timeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Admin Activity Timeline</CardTitle>
|
||||
<CardDescription>
|
||||
Admin actions over time
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : activityData?.timelineData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={activityData.timelineData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="actions" fill="#8884d8" />
|
||||
<Bar dataKey="approvals" fill="#82ca9d" />
|
||||
<Bar dataKey="rejections" fill="#ff7300" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No activity timeline data available for the selected period.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Admin Performance Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Admin Performance</CardTitle>
|
||||
<CardDescription>
|
||||
Individual admin activity breakdown
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{userWiseLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : userWiseData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={userWiseData.slice(0, 10)}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="adminName" angle={-45} textAnchor="end" height={80} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="totalActions" fill="#0088FE" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No admin performance data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity by Category */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity by Category</CardTitle>
|
||||
<CardDescription>
|
||||
Admin actions distributed by ad categories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{categoryLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : categoryData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryData.slice(0, 8)}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="totalActions"
|
||||
label={({ categoryName, totalActions }) => `${categoryName}: ${totalActions}`}
|
||||
>
|
||||
{categoryData.slice(0, 8).map((entry: any, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No category activity data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Type Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Action Type Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
Breakdown of admin action types
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activityLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : activityData?.summary ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: "Approvals", value: activityData.summary.approvals, color: "#00C49F" },
|
||||
{ name: "Rejections", value: activityData.summary.rejections, color: "#FF8042" },
|
||||
{ name: "Holds", value: activityData.summary.holds, color: "#FFBB28" }
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{[{ color: "#00C49F" }, { color: "#FF8042" }, { color: "#FFBB28" }].map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No action type data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Admin Performance Table */}
|
||||
{userWiseData && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detailed Admin Performance</CardTitle>
|
||||
<CardDescription>
|
||||
Individual admin activity statistics and performance metrics
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">Admin</th>
|
||||
<th className="text-left p-2">Total Actions</th>
|
||||
<th className="text-left p-2">Approvals</th>
|
||||
<th className="text-left p-2">Rejections</th>
|
||||
<th className="text-left p-2">Holds</th>
|
||||
<th className="text-left p-2">Reviews</th>
|
||||
<th className="text-left p-2">Response Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userWiseData.map((admin: any, index: number) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="p-2 font-medium">{admin.adminName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="secondary">{admin.totalActions}</Badge>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
{admin.approvals}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="default" className="bg-red-100 text-red-800">
|
||||
{admin.rejections}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="default" className="bg-yellow-100 text-yellow-800">
|
||||
{admin.holds}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2">{admin.reviews}</td>
|
||||
<td className="p-2">
|
||||
{admin.avgTimeToAction ? `${admin.avgTimeToAction}h` : "N/A"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import api from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ExportDataButtonProps {
|
||||
type?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function ExportDataButton({
|
||||
type,
|
||||
status,
|
||||
startDate,
|
||||
endDate,
|
||||
}: ExportDataButtonProps) {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Only enable if type is selected
|
||||
const isDisabled = !type || isExporting;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!type) {
|
||||
toast.error("Please select an ad type before exporting");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", type);
|
||||
|
||||
if (status) {
|
||||
params.set("status", status);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
params.set("startDate", startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
params.set("endDate", endDate);
|
||||
}
|
||||
|
||||
// Get all data for export (limit to 1000 for practical reasons)
|
||||
params.set("page", "1");
|
||||
params.set("limit", "1000");
|
||||
|
||||
const { data } = await api.get(`/reports/ad-stats?${params.toString()}`);
|
||||
const ads = data[0];
|
||||
|
||||
if (!ads || ads.length === 0) {
|
||||
toast.error("No data to export");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to CSV
|
||||
const headers = [
|
||||
"Sequence Number",
|
||||
"Content",
|
||||
"Status",
|
||||
"Main Category",
|
||||
"Sub Categories",
|
||||
"State",
|
||||
"City",
|
||||
"Publication Dates",
|
||||
"Customer Name",
|
||||
"Customer Email",
|
||||
"Created Date",
|
||||
];
|
||||
|
||||
const csvRows = [
|
||||
headers.join(","),
|
||||
...ads.map((ad: any) => {
|
||||
const row = [
|
||||
ad.sequenceNumber,
|
||||
`"${(ad.content || "").replace(/"/g, '""')}"`,
|
||||
ad.status,
|
||||
ad.mainCategory?.name || "",
|
||||
`"${[
|
||||
ad.categoryOne?.name,
|
||||
ad.categoryTwo?.name,
|
||||
ad.categoryThree?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}"`,
|
||||
ad.state || "",
|
||||
ad.city || "",
|
||||
`"${
|
||||
ad.dates
|
||||
?.map((d: string) => {
|
||||
const date = new Date(
|
||||
typeof d === "string" && d.length === 10
|
||||
? `${d}T00:00:00`
|
||||
: d
|
||||
);
|
||||
return date.toLocaleDateString();
|
||||
})
|
||||
.join(", ") || ""
|
||||
}"`,
|
||||
ad.customer?.user?.name || "",
|
||||
ad.customer?.user?.email || "",
|
||||
new Date(ad.created_at).toLocaleDateString(),
|
||||
];
|
||||
return row.join(",");
|
||||
}),
|
||||
];
|
||||
|
||||
const csvContent = csvRows.join("\n");
|
||||
|
||||
// Create download
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`${type.toLowerCase()}-ads-report-${
|
||||
new Date().toISOString().split("T")[0]
|
||||
}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.success("Report exported successfully");
|
||||
} catch (error) {
|
||||
console.error("Export error:", error);
|
||||
toast.error("Failed to export data");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={isDisabled}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{isExporting ? "Exporting..." : "Export CSV"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import api from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getStatusVariant, truncateContent } from "@/lib/utils";
|
||||
import { AdStatus } from "@/lib/enum/ad-status";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
|
||||
interface FilteredAdsTableProps {
|
||||
type?: AdType;
|
||||
status?: AdStatus;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function FilteredAdsTable({
|
||||
type,
|
||||
status,
|
||||
startDate,
|
||||
endDate,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
}: FilteredAdsTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Only fetch data if type is selected
|
||||
const shouldFetch = !!type;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["ad-stats", type, status, startDate, endDate, page, limit],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (type) {
|
||||
params.set("type", type);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
params.set("status", status);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
params.set("startDate", startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
params.set("endDate", endDate);
|
||||
}
|
||||
|
||||
params.set("page", page.toString());
|
||||
params.set("limit", limit.toString());
|
||||
|
||||
const { data } = await api.get(`/reports/ad-stats?${params.toString()}`);
|
||||
|
||||
// API returns [items, totalCount]
|
||||
const items = data[0];
|
||||
const total = data[1];
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
};
|
||||
},
|
||||
enabled: shouldFetch,
|
||||
});
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", newPage.toString());
|
||||
router.push(`/mgmt/dashboard/reports?${params.toString()}`);
|
||||
};
|
||||
|
||||
if (!shouldFetch) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted p-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Please select an ad type to view reports.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md bg-destructive/15 p-4 text-center">
|
||||
<p className="text-destructive">Error loading ads. Please try again.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !data.items || data.items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted p-4 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No ads found matching the selected criteria.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(data.total / limit);
|
||||
|
||||
// Determine view link based on ad type
|
||||
const getViewLink = (ad: any) => {
|
||||
switch (type) {
|
||||
case AdType.LINE:
|
||||
return `/mgmt/dashboard/review-ads/line/view/${ad.id}`;
|
||||
case AdType.POSTER:
|
||||
return `/mgmt/dashboard/review-ads/poster/view/${ad.id}`;
|
||||
case AdType.VIDEO:
|
||||
return `/mgmt/dashboard/review-ads/video/view/${ad.id}`;
|
||||
default:
|
||||
return `/mgmt/dashboard/review-ads/line/view/${ad.id}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the appropriate image based on ad type
|
||||
const getAdImage = (ad: any) => {
|
||||
if (type === AdType.LINE) {
|
||||
// Line ads may have multiple images or none
|
||||
return ad.images && ad.images.length > 0 ? ad.images[0] : null;
|
||||
} else if (type === AdType.POSTER) {
|
||||
// Poster ads have a single image
|
||||
return ad.image;
|
||||
} else if (type === AdType.VIDEO) {
|
||||
// Video ads have a single image (thumbnail)
|
||||
return ad.image;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Get the appropriate content based on ad type
|
||||
const getAdContent = (ad: any) => {
|
||||
if (type === AdType.LINE) {
|
||||
return truncateContent(ad.content) || "No content";
|
||||
} else if (type === AdType.POSTER) {
|
||||
// For poster ads, we might want to show a title or description
|
||||
return ad.title || "Poster Ad";
|
||||
} else if (type === AdType.VIDEO) {
|
||||
// For video ads, we might want to show a title or description
|
||||
return ad.title || "Video Ad";
|
||||
}
|
||||
return "No content";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Seq #</TableHead>
|
||||
<TableHead>Content</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Categories</TableHead>
|
||||
<TableHead>Publication Dates</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((ad: any) => {
|
||||
const image = getAdImage(ad);
|
||||
|
||||
return (
|
||||
<TableRow key={ad.id}>
|
||||
<TableCell className="font-medium">
|
||||
{ad.sequenceNumber}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{image && (
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src={`/api/images?imageName=${image.fileName}`}
|
||||
alt="Ad image"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="line-clamp-2">
|
||||
{ad.content
|
||||
? truncateContent(ad.content)
|
||||
: "No content"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getStatusVariant(ad.status) as any}>
|
||||
{ad.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs">{ad.mainCategory?.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{ad.categoryOne?.name}{" "}
|
||||
{ad.categoryTwo?.name && `/ ${ad.categoryTwo.name}`}{" "}
|
||||
{ad.categoryThree?.name && `/ ${ad.categoryThree.name}`}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1 text-xs">
|
||||
{ad.dates && ad.dates.length > 0 ? (
|
||||
ad.dates
|
||||
.slice(0, 2)
|
||||
.map((date: string, i: number) => (
|
||||
<div key={i}>
|
||||
{format(
|
||||
new Date(
|
||||
typeof date === "string" && date.length === 10
|
||||
? `${date}T00:00:00`
|
||||
: date
|
||||
),
|
||||
"dd MMM yyyy"
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">No dates</span>
|
||||
)}
|
||||
{ad.dates && ad.dates.length > 2 && (
|
||||
<div className="text-muted-foreground">
|
||||
+{ad.dates.length - 2} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs">
|
||||
{ad.customer?.user?.name || "Unknown"}
|
||||
<div className="text-muted-foreground">
|
||||
{ad.customer?.user?.email || ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link href={getViewLink(ad)}>
|
||||
<Button variant="outline" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages || 1}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
504
src/app/mgmt/dashboard/reports/listing-reports.tsx
Normal file
504
src/app/mgmt/dashboard/reports/listing-reports.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, Download, FileText, Image as ImageIcon, Clock, Users } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import api from "@/lib/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from "recharts";
|
||||
|
||||
interface ListingReportFilters {
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
}
|
||||
|
||||
export function ListingReports() {
|
||||
const [filters, setFilters] = useState<ListingReportFilters>({
|
||||
startDate: new Date(new Date().setMonth(new Date().getMonth() - 1)),
|
||||
endDate: new Date()
|
||||
});
|
||||
|
||||
// Comprehensive Listing Analytics
|
||||
const { data: analyticsData, isLoading: analyticsLoading } = useQuery({
|
||||
queryKey: ["listing-analytics", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
|
||||
const { data } = await api.get(`/reports/listings/analytics?${params}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!(filters.startDate && filters.endDate)
|
||||
});
|
||||
|
||||
// Active Listings by Category
|
||||
const { data: activeByCategoryData, isLoading: activeByCategoryLoading } = useQuery({
|
||||
queryKey: ["active-by-category"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/reports/listings/active-by-category");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
// Approval Time Analytics
|
||||
const { data: approvalTimesData, isLoading: approvalTimesLoading } = useQuery({
|
||||
queryKey: ["approval-times"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/reports/listings/approval-times");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
// Listings by User
|
||||
const { data: byUserData, isLoading: byUserLoading } = useQuery({
|
||||
queryKey: ["listings-by-user"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/reports/listings/by-user");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
const handleFilterChange = (key: keyof ListingReportFilters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const exportData = async (reportType: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
params.set("format", "csv");
|
||||
params.set("reportType", reportType);
|
||||
|
||||
const response = await api.get(`/reports/export?${params}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${reportType}-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Listing Analytics Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure date ranges for listing performance reports
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Start Date</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!filters.startDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.startDate ? format(filters.startDate, "PPP") : "Pick start date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.startDate || undefined}
|
||||
onSelect={(date) => handleFilterChange("startDate", date || null)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">End Date</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!filters.endDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.endDate ? format(filters.endDate, "PPP") : "Pick end date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.endDate || undefined}
|
||||
onSelect={(date) => handleFilterChange("endDate", date || null)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Actions</label>
|
||||
<Button
|
||||
onClick={() => exportData("listing-analytics")}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Listings</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{analyticsData?.summary?.totalListings || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All listings on platform
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">With Images</CardTitle>
|
||||
<ImageIcon className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{analyticsData?.summary?.withImages || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{analyticsData?.summary?.totalListings
|
||||
? Math.round((analyticsData.summary.withImages / analyticsData.summary.totalListings) * 100)
|
||||
: 0}% have images
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Approval</CardTitle>
|
||||
<Clock className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{analyticsData?.summary?.avgApprovalTime || 0}h
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Average approval time
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{byUserLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{byUserData?.length || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Users with listings
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Listings by Category */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Listings by Category</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution of active listings across categories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeByCategoryLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : activeByCategoryData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={activeByCategoryData.slice(0, 10)}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="categoryName" angle={-45} textAnchor="end" height={100} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="total" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No category data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Listings by User Type */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Listings by User Type</CardTitle>
|
||||
<CardDescription>
|
||||
Individual vs Business user listing distribution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : analyticsData?.byUserType ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={analyticsData.byUserType}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
label={({ userType, percentage }) => `${userType}: ${percentage}%`}
|
||||
>
|
||||
{analyticsData.byUserType.map((entry: any, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No user type data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Image vs No Image Distribution */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Image Usage Statistics</CardTitle>
|
||||
<CardDescription>
|
||||
Listings with vs without images
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : analyticsData?.summary ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: "With Images", value: analyticsData.summary.withImages, color: "#00C49F" },
|
||||
{ name: "Without Images", value: analyticsData.summary.withoutImages, color: "#FF8042" }
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{[{ color: "#00C49F" }, { color: "#FF8042" }].map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No image usage data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Performing Categories */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Performance</CardTitle>
|
||||
<CardDescription>
|
||||
Categories by listing count and approval metrics
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{analyticsLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : analyticsData?.byCategory ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={analyticsData.byCategory.slice(0, 8)}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="categoryName" angle={-45} textAnchor="end" height={80} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="totalAds" fill="#0088FE" name="Total Ads" />
|
||||
<Bar dataKey="activeAds" fill="#00C49F" name="Active Ads" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No category performance data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Category Breakdown */}
|
||||
{analyticsData?.byCategory && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detailed Category Analytics</CardTitle>
|
||||
<CardDescription>
|
||||
Comprehensive category performance metrics and approval times
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">Category</th>
|
||||
<th className="text-left p-2">Total Ads</th>
|
||||
<th className="text-left p-2">Active Ads</th>
|
||||
<th className="text-left p-2">With Images</th>
|
||||
<th className="text-left p-2">Without Images</th>
|
||||
<th className="text-left p-2">Avg Approval Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analyticsData.byCategory.map((category: any, index: number) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="p-2 font-medium">{category.categoryName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="secondary">{category.totalAds}</Badge>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="default" className="bg-green-100 text-green-800">
|
||||
{category.activeAds}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2">{category.withImages}</td>
|
||||
<td className="p-2">{category.withoutImages}</td>
|
||||
<td className="p-2">
|
||||
{category.avgApprovalTime ? `${category.avgApprovalTime}h` : "N/A"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Top Users by Listings */}
|
||||
{byUserData && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Users by Listings</CardTitle>
|
||||
<CardDescription>
|
||||
Users with the most active listings on the platform
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">User</th>
|
||||
<th className="text-left p-2">User Type</th>
|
||||
<th className="text-left p-2">Line Ads</th>
|
||||
<th className="text-left p-2">Poster Ads</th>
|
||||
<th className="text-left p-2">Video Ads</th>
|
||||
<th className="text-left p-2">Total Ads</th>
|
||||
<th className="text-left p-2">Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{byUserData.slice(0, 20).map((user: any, index: number) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="p-2 font-medium">{user.userName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="outline">{user.userType}</Badge>
|
||||
</td>
|
||||
<td className="p-2">{user.lineAds}</td>
|
||||
<td className="p-2">{user.posterAds}</td>
|
||||
<td className="p-2">{user.videoAds}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="secondary">{user.totalAds}</Badge>
|
||||
</td>
|
||||
<td className="p-2 text-xs text-muted-foreground">{user.location}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ExportDataButton } from "./export-data";
|
||||
import { FilteredAdsTable } from "./filtered-ads-table";
|
||||
import { ReportsFilter } from "./reports-filter";
|
||||
import { ReportsSummary } from "./report-summary";
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { SimpleReportsFilter } from './simple-reports-filter'
|
||||
import { SimpleReportsTable } from "./simple-reports-table";
|
||||
import { UserReports } from "./user-reports";
|
||||
import { AdminReports } from "./admin-reports";
|
||||
import { ListingReports } from "./listing-reports";
|
||||
import { PaymentReports } from "./payment-reports";
|
||||
import { BarChart3, Users, UserCheck, FileText, CreditCard } from "lucide-react";
|
||||
|
||||
export interface ReportsFilters {
|
||||
adType: string;
|
||||
status: string;
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
state: string;
|
||||
city: string;
|
||||
userType: string;
|
||||
mainCategory: string;
|
||||
categoryOne: string;
|
||||
categoryTwo: string;
|
||||
categoryThree: string;
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [filters, setFilters] = useState<ReportsFilters>({
|
||||
adType: "",
|
||||
status: "",
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
state: "",
|
||||
city: "",
|
||||
userType: "",
|
||||
mainCategory: "",
|
||||
categoryOne: "",
|
||||
categoryTwo: "",
|
||||
categoryThree: "",
|
||||
});
|
||||
|
||||
// Get filter values from URL
|
||||
const type = searchParams.get("type") || undefined;
|
||||
const status = searchParams.get("status") || undefined;
|
||||
const startDate = searchParams.get("startDate") || undefined;
|
||||
const endDate = searchParams.get("endDate") || undefined;
|
||||
const page = searchParams.get("page")
|
||||
? Number.parseInt(searchParams.get("page") as string)
|
||||
: 1;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState("filtered-ads");
|
||||
|
||||
// Default limit
|
||||
const limit = 10;
|
||||
|
||||
// Format the type for display
|
||||
const getTypeDisplay = () => {
|
||||
if (!type) return "All Ads";
|
||||
|
||||
switch (type) {
|
||||
case AdType.LINE:
|
||||
return "Line Ads";
|
||||
case AdType.POSTER:
|
||||
return "Poster Ads";
|
||||
case AdType.VIDEO:
|
||||
return "Video Ads";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
const handleFiltersChange = (newFilters: ReportsFilters) => {
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Reports</h2>
|
||||
<ExportDataButton
|
||||
type={type}
|
||||
status={status}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">📊 Comprehensive Reports</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Advanced analytics and business intelligence for Paisa Ads platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ReportsFilter />
|
||||
{/* Report Categories Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="filtered-ads" className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Filtered Ads
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="user-reports" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
User Reports
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admin-reports" className="flex items-center gap-2">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Admin Reports
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="listing-reports" className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Listing Reports
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="payment-reports" className="flex items-center gap-2">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Payment Reports
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xl font-semibold">
|
||||
{getTypeDisplay()} {status ? `- ${status.replace(/_/g, " ")}` : ""}
|
||||
{(startDate || endDate) && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
{startDate ? new Date(startDate).toLocaleDateString() : "Any"} to{" "}
|
||||
{endDate ? new Date(endDate).toLocaleDateString() : "Any"}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{/* Filtered Ads Tab (Original Functionality) */}
|
||||
<TabsContent value="filtered-ads" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
Filtered Ads Report
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Filter and analyze ads data based on various criteria
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<SimpleReportsFilter
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
/>
|
||||
<SimpleReportsTable
|
||||
filters={filters}
|
||||
currentPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<ReportsSummary
|
||||
type={type}
|
||||
// @ts-ignore
|
||||
status={status}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
{/* User Reports Tab */}
|
||||
<TabsContent value="user-reports" className="space-y-6">
|
||||
<UserReports />
|
||||
</TabsContent>
|
||||
|
||||
<FilteredAdsTable
|
||||
// @ts-ignore
|
||||
type={type}
|
||||
// @ts-ignore
|
||||
status={status}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
page={page}
|
||||
limit={limit}
|
||||
/>
|
||||
</div>
|
||||
{/* Admin Reports Tab */}
|
||||
<TabsContent value="admin-reports" className="space-y-6">
|
||||
<AdminReports />
|
||||
</TabsContent>
|
||||
|
||||
{/* Listing Reports Tab */}
|
||||
<TabsContent value="listing-reports" className="space-y-6">
|
||||
<ListingReports />
|
||||
</TabsContent>
|
||||
|
||||
{/* Payment Reports Tab */}
|
||||
<TabsContent value="payment-reports" className="space-y-6">
|
||||
<PaymentReports />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
509
src/app/mgmt/dashboard/reports/payment-reports.tsx
Normal file
509
src/app/mgmt/dashboard/reports/payment-reports.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, Download, CreditCard, TrendingUp, DollarSign, Receipt } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import api from "@/lib/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line, Area, AreaChart } from "recharts";
|
||||
|
||||
interface PaymentReportFilters {
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
period: "daily" | "weekly" | "monthly";
|
||||
}
|
||||
|
||||
export function PaymentReports() {
|
||||
const [filters, setFilters] = useState<PaymentReportFilters>({
|
||||
startDate: new Date(new Date().setMonth(new Date().getMonth() - 1)),
|
||||
endDate: new Date(),
|
||||
period: "monthly"
|
||||
});
|
||||
|
||||
// Payment Transaction Report
|
||||
const { data: transactionData, isLoading: transactionLoading } = useQuery({
|
||||
queryKey: ["payment-transactions", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
params.set("period", filters.period);
|
||||
|
||||
const { data } = await api.get(`/reports/payments/transactions?${params}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!(filters.startDate && filters.endDate)
|
||||
});
|
||||
|
||||
// Revenue by Product Type
|
||||
const { data: revenueByProductData, isLoading: revenueByProductLoading } = useQuery({
|
||||
queryKey: ["revenue-by-product"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/reports/payments/revenue-by-product");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
// Revenue by Category
|
||||
const { data: revenueByCategoryData, isLoading: revenueByCategoryLoading } = useQuery({
|
||||
queryKey: ["revenue-by-category"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/reports/payments/revenue-by-category");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
const handleFilterChange = (key: keyof PaymentReportFilters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const exportData = async (reportType: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
params.set("format", "csv");
|
||||
params.set("reportType", reportType);
|
||||
|
||||
const response = await api.get(`/reports/export?${params}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${reportType}-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
Payment Analytics Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure date ranges and grouping for payment reports
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Start Date</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!filters.startDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.startDate ? format(filters.startDate, "PPP") : "Pick start date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.startDate || undefined}
|
||||
onSelect={(date) => handleFilterChange("startDate", date || null)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">End Date</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!filters.endDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.endDate ? format(filters.endDate, "PPP") : "Pick end date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.endDate || undefined}
|
||||
onSelect={(date) => handleFilterChange("endDate", date || null)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Period</label>
|
||||
<Select value={filters.period} onValueChange={(value: "daily" | "weekly" | "monthly") => handleFilterChange("period", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Actions</label>
|
||||
<Button
|
||||
onClick={() => exportData("payment-transactions")}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transactionLoading ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(transactionData?.summary?.totalRevenue || 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total platform revenue
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Transactions</CardTitle>
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transactionLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{transactionData?.summary?.totalTransactions || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Completed transactions
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Transaction</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-blue-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transactionLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{formatCurrency(transactionData?.summary?.avgTransactionValue || 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Average transaction value
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
|
||||
<CreditCard className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transactionLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{transactionData?.summary?.successRate || 100}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Payment success rate
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Timeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue Timeline</CardTitle>
|
||||
<CardDescription>
|
||||
Revenue trends over selected period
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transactionLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : transactionData?.timeline ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={transactionData.timeline}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="period" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => formatCurrency(Number(value))} />
|
||||
<Area type="monotone" dataKey="revenue" stroke="#0088FE" fill="#0088FE" fillOpacity={0.6} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No revenue timeline data available for the selected period.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revenue by Product Type */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Revenue by Product Type</CardTitle>
|
||||
<CardDescription>
|
||||
Revenue distribution across ad types
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{revenueByProductLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : revenueByProductData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: "Line Ads", value: revenueByProductData.lineAds?.revenue || 0, color: "#0088FE" },
|
||||
{ name: "Poster Ads", value: revenueByProductData.posterAds?.revenue || 0, color: "#00C49F" },
|
||||
{ name: "Video Ads", value: revenueByProductData.videoAds?.revenue || 0, color: "#FFBB28" }
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{[{ color: "#0088FE" }, { color: "#00C49F" }, { color: "#FFBB28" }].map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value) => formatCurrency(Number(value))} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No product revenue data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transaction Volume */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transaction Volume</CardTitle>
|
||||
<CardDescription>
|
||||
Number of transactions over time
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transactionLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : transactionData?.timeline ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={transactionData.timeline}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="period" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="transactions" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No transaction volume data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revenue by Category */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Categories by Revenue</CardTitle>
|
||||
<CardDescription>
|
||||
Highest revenue generating categories
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{revenueByCategoryLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : revenueByCategoryData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={revenueByCategoryData.slice(0, 8)}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="categoryName" angle={-45} textAnchor="end" height={80} />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => formatCurrency(Number(value))} />
|
||||
<Bar dataKey="revenue" fill="#FF8042" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No category revenue data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Product Performance Table */}
|
||||
{revenueByProductData && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Product Performance Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed revenue and transaction metrics by product type
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">Product Type</th>
|
||||
<th className="text-left p-2">Revenue</th>
|
||||
<th className="text-left p-2">Transactions</th>
|
||||
<th className="text-left p-2">Avg Value</th>
|
||||
<th className="text-left p-2">Revenue Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ name: "Line Ads", data: revenueByProductData.lineAds },
|
||||
{ name: "Poster Ads", data: revenueByProductData.posterAds },
|
||||
{ name: "Video Ads", data: revenueByProductData.videoAds }
|
||||
].map((product, index) => {
|
||||
const totalRevenue = (revenueByProductData.lineAds?.revenue || 0) +
|
||||
(revenueByProductData.posterAds?.revenue || 0) +
|
||||
(revenueByProductData.videoAds?.revenue || 0);
|
||||
const share = totalRevenue > 0 ? ((product.data?.revenue || 0) / totalRevenue * 100).toFixed(1) : 0;
|
||||
|
||||
return (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="p-2 font-medium">{product.name}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="secondary" className="text-green-700">
|
||||
{formatCurrency(product.data?.revenue || 0)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2">{product.data?.transactions || 0}</td>
|
||||
<td className="p-2">{formatCurrency(product.data?.avgValue || 0)}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="outline">{share}%</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Category Revenue Table */}
|
||||
{revenueByCategoryData && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Category Revenue Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Revenue performance by category with transaction details
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">Category</th>
|
||||
<th className="text-left p-2">Revenue</th>
|
||||
<th className="text-left p-2">Transactions</th>
|
||||
<th className="text-left p-2">Avg Transaction Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{revenueByCategoryData.slice(0, 15).map((category: any, index: number) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="p-2 font-medium">{category.categoryName}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="secondary" className="text-green-700">
|
||||
{formatCurrency(category.revenue)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2">{category.transactions}</td>
|
||||
<td className="p-2">{formatCurrency(category.avgTransactionValue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ReportsSummaryProps {
|
||||
type?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function ReportsSummary({
|
||||
type,
|
||||
status,
|
||||
startDate,
|
||||
endDate,
|
||||
}: ReportsSummaryProps) {
|
||||
// Only fetch if type is selected
|
||||
const shouldFetch = !!type;
|
||||
|
||||
// Get summary stats for the filtered data
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["ad-stats-summary", type, status, startDate, endDate],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (type) {
|
||||
params.set("type", type);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
params.set("status", status);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
params.set("startDate", startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
params.set("endDate", endDate);
|
||||
}
|
||||
|
||||
// Set limit to 1 just to get the total count
|
||||
params.set("page", "1");
|
||||
params.set("limit", "1");
|
||||
|
||||
const { data } = await api.get(`/reports/ad-stats?${params.toString()}`);
|
||||
return {
|
||||
filteredCount: data[1] || 0,
|
||||
};
|
||||
},
|
||||
enabled: shouldFetch,
|
||||
});
|
||||
|
||||
// Get total count for the selected ad type (without other filters)
|
||||
const { data: typeData, isLoading: typeLoading } = useQuery({
|
||||
queryKey: ["ad-type-total", type],
|
||||
queryFn: async () => {
|
||||
if (!type) return { total: 0 };
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", type);
|
||||
params.set("page", "1");
|
||||
params.set("limit", "1");
|
||||
|
||||
const { data } = await api.get(`/reports/ad-stats?${params.toString()}`);
|
||||
return {
|
||||
total: data[1] || 0,
|
||||
};
|
||||
},
|
||||
enabled: shouldFetch,
|
||||
});
|
||||
|
||||
if (!shouldFetch) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Select Filters
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground">
|
||||
Please select an ad type to view summary statistics.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || typeLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || !typeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalTypeAds = typeData.total || 0;
|
||||
const filteredCount = data.filteredCount || 0;
|
||||
const percentage =
|
||||
totalTypeAds > 0 ? Math.round((filteredCount / totalTypeAds) * 100) : 0;
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return "Any";
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total {type} Ads
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalTypeAds}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{status ? status.replace(/_/g, " ") : "All"} {type} Ads
|
||||
{(startDate || endDate) && (
|
||||
<span className="block text-xs font-normal text-muted-foreground mt-1">
|
||||
{formatDate(startDate)} to {formatDate(endDate)}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{filteredCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Percentage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{percentage}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AdStatus } from "@/lib/enum/ad-status";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
|
||||
const formSchema = z.object({
|
||||
type: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
startDate: z.date().optional(),
|
||||
endDate: z.date().optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function ReportsFilter() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Initialize form with URL params
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
type: searchParams.get("type") || undefined,
|
||||
status: searchParams.get("status") || undefined,
|
||||
startDate: searchParams.get("startDate")
|
||||
? new Date(searchParams.get("startDate") as string)
|
||||
: undefined,
|
||||
endDate: searchParams.get("endDate")
|
||||
? new Date(searchParams.get("endDate") as string)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when URL params change
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
type: searchParams.get("type") || undefined,
|
||||
status: searchParams.get("status") || undefined,
|
||||
startDate: searchParams.get("startDate")
|
||||
? new Date(searchParams.get("startDate") as string)
|
||||
: undefined,
|
||||
endDate: searchParams.get("endDate")
|
||||
? new Date(searchParams.get("endDate") as string)
|
||||
: undefined,
|
||||
});
|
||||
}, [searchParams, form]);
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (values.type) {
|
||||
params.set("type", values.type);
|
||||
}
|
||||
|
||||
if (values.status) {
|
||||
params.set("status", values.status);
|
||||
}
|
||||
|
||||
if (values.startDate) {
|
||||
params.set("startDate", values.startDate.toISOString().split("T")[0]);
|
||||
}
|
||||
|
||||
if (values.endDate) {
|
||||
params.set("endDate", values.endDate.toISOString().split("T")[0]);
|
||||
}
|
||||
|
||||
// Reset to page 1 when applying new filters
|
||||
params.set("page", "1");
|
||||
|
||||
router.push(`/mgmt/dashboard/reports?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
form.reset({
|
||||
type: undefined,
|
||||
status: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
});
|
||||
router.push("/mgmt/dashboard/reports");
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ad Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ad type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={AdType.LINE}>Line Ads</SelectItem>
|
||||
<SelectItem value={AdType.POSTER}>Poster Ads</SelectItem>
|
||||
<SelectItem value={AdType.VIDEO}>Video Ads</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={AdStatus.DRAFT}>Draft</SelectItem>
|
||||
|
||||
<SelectItem value={AdStatus.YET_TO_BE_PUBLISHED}>
|
||||
Yet To Be Published
|
||||
</SelectItem>
|
||||
<SelectItem value={AdStatus.PUBLISHED}>
|
||||
Published
|
||||
</SelectItem>
|
||||
<SelectItem value={AdStatus.REJECTED}>Rejected</SelectItem>
|
||||
<SelectItem value={AdStatus.HOLD}>Hold</SelectItem>
|
||||
{/* <SelectItem value={AdStatus.REJECTED}>Rejected</SelectItem> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="startDate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Start Date</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endDate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>End Date</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={handleReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit">Apply Filters</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
412
src/app/mgmt/dashboard/reports/simple-reports-filter.tsx
Normal file
412
src/app/mgmt/dashboard/reports/simple-reports-filter.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CalendarIcon, FilterIcon, XIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdStatus } from "@/lib/enum/ad-status";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { ReportsFilters } from "./page";
|
||||
import { PostedBy } from "@/lib/enum/posted-by";
|
||||
interface SimpleReportsFilterProps {
|
||||
filters: ReportsFilters;
|
||||
onFiltersChange: (filters: ReportsFilters) => void;
|
||||
}
|
||||
|
||||
export function SimpleReportsFilter({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
}: SimpleReportsFilterProps) {
|
||||
// Fetch locations for dropdowns
|
||||
const { data: locations } = useQuery({
|
||||
queryKey: ["filter-locations"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/reports/locations");
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch categories for dropdowns
|
||||
const { data: categories, isLoading: categoriesLoading, error: categoriesError } = useQuery({
|
||||
queryKey: ["filter-categories"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/categories/tree");
|
||||
console.log("Categories data:", data); // Debug log
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Log categories error if any
|
||||
if (categoriesError) {
|
||||
console.error("Error loading categories:", categoriesError);
|
||||
}
|
||||
|
||||
// Handle filter changes with real-time updates
|
||||
const handleFilterChange = (key: keyof ReportsFilters, value: any) => {
|
||||
const newFilters: ReportsFilters = { ...filters, [key]: value };
|
||||
|
||||
if (key === "mainCategory") {
|
||||
newFilters.categoryOne = "";
|
||||
newFilters.categoryTwo = "";
|
||||
newFilters.categoryThree = "";
|
||||
} else if (key === "categoryOne") {
|
||||
newFilters.categoryTwo = "";
|
||||
newFilters.categoryThree = "";
|
||||
} else if (key === "categoryTwo") {
|
||||
newFilters.categoryThree = "";
|
||||
}
|
||||
|
||||
onFiltersChange(newFilters);
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const clearAllFilters = () => {
|
||||
onFiltersChange({
|
||||
adType: "",
|
||||
status: "",
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
state: "",
|
||||
city: "",
|
||||
userType: "",
|
||||
mainCategory: "",
|
||||
categoryOne: "",
|
||||
categoryTwo: "",
|
||||
categoryThree: "",
|
||||
});
|
||||
};
|
||||
|
||||
// Count active filters
|
||||
const activeFiltersCount = Object.values(filters).filter((value) => {
|
||||
if (value === null || value === "" || value === undefined) return false;
|
||||
return true;
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<CardHeader className="p-0 mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-xl font-semibold">
|
||||
<FilterIcon className="h-5 w-5" />
|
||||
Filters
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="bg-primary text-primary-foreground text-xs px-2 py-1 rounded-full">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
{activeFiltersCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAllFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid gap-6">
|
||||
{/* Row 1: Ad Type, Status, and Location */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adType">Ad Type</Label>
|
||||
<Select
|
||||
value={filters.adType}
|
||||
onValueChange={(value) => handleFilterChange("adType", value)}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select ad type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AdType.LINE}>Line Ads</SelectItem>
|
||||
<SelectItem value={AdType.POSTER}>Poster Ads</SelectItem>
|
||||
<SelectItem value={AdType.VIDEO}>Video Ads</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => handleFilterChange("status", value)}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={AdStatus.DRAFT}>Draft</SelectItem>
|
||||
<SelectItem value={AdStatus.FOR_REVIEW}>
|
||||
For Review
|
||||
</SelectItem>
|
||||
<SelectItem value={AdStatus.REJECTED}>Rejected</SelectItem>
|
||||
<SelectItem value={AdStatus.HOLD}>Hold</SelectItem>
|
||||
<SelectItem value={AdStatus.YET_TO_BE_PUBLISHED}>
|
||||
Yet To Be Published
|
||||
</SelectItem>
|
||||
<SelectItem value={AdStatus.PUBLISHED}>Published</SelectItem>
|
||||
<SelectItem value={AdStatus.PAUSED}>Paused</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="state">State</Label>
|
||||
<Select
|
||||
value={filters.state}
|
||||
onValueChange={(value) => handleFilterChange("state", value)}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select state" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations?.states?.map((state: any) => (
|
||||
<SelectItem key={state.name} value={state.name}>
|
||||
{state.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">City</Label>
|
||||
<Select
|
||||
value={filters.city}
|
||||
onValueChange={(value) => handleFilterChange("city", value)}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select city" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations?.cities
|
||||
?.filter(
|
||||
(city: any) =>
|
||||
!filters.state || city.state === filters.state
|
||||
)
|
||||
?.map((city: any) => (
|
||||
<SelectItem key={city.name} value={city.name}>
|
||||
{city.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: User Type and Categories */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userType">User Type</Label>
|
||||
<Select
|
||||
value={filters.userType}
|
||||
onValueChange={(value) => handleFilterChange("userType", value)}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select user type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PostedBy.OWNER}>
|
||||
{PostedBy.OWNER}
|
||||
</SelectItem>
|
||||
<SelectItem value={PostedBy.PROMOTERDEVELOPER}>
|
||||
{PostedBy.PROMOTERDEVELOPER}
|
||||
</SelectItem>
|
||||
<SelectItem value={PostedBy.AGENCY}>
|
||||
{PostedBy.AGENCY}
|
||||
</SelectItem>
|
||||
<SelectItem value={PostedBy.DEALER}>
|
||||
{PostedBy.DEALER}
|
||||
</SelectItem>
|
||||
<SelectItem value={PostedBy.OTHERS}>
|
||||
{PostedBy.OTHERS}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mainCategory">Main Category</Label>
|
||||
<Select
|
||||
value={filters.mainCategory}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("mainCategory", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select main category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories?.map((category: any) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryOne">Category 1</Label>
|
||||
<Select
|
||||
value={filters.categoryOne}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("categoryOne", value)
|
||||
}
|
||||
disabled={!filters.mainCategory}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select category one" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories
|
||||
?.find((cat: any) => cat.id === filters.mainCategory)
|
||||
?.subCategories?.map((category: any) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryTwo">Category 2</Label>
|
||||
<Select
|
||||
value={filters.categoryTwo}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("categoryTwo", value)
|
||||
}
|
||||
disabled={!filters.categoryOne}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select category two" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories
|
||||
?.find((cat: any) => cat.id === filters.mainCategory)
|
||||
?.subCategories?.find(
|
||||
(cat: any) => cat.id === filters.categoryOne
|
||||
)
|
||||
?.subCategories?.map((category: any) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryThree">Category 3</Label>
|
||||
<Select
|
||||
value={filters.categoryThree}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("categoryThree", value)
|
||||
}
|
||||
disabled={!filters.categoryTwo}
|
||||
>
|
||||
<SelectTrigger className="p-2">
|
||||
<SelectValue placeholder="Select category three" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories
|
||||
?.find((cat: any) => cat.id === filters.mainCategory)
|
||||
?.subCategories?.find(
|
||||
(cat: any) => cat.id === filters.categoryOne
|
||||
)
|
||||
?.subCategories?.find(
|
||||
(cat: any) => cat.id === filters.categoryTwo
|
||||
)
|
||||
?.subCategories?.map((category: any) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Date Range */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal p-2",
|
||||
!filters.startDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.startDate
|
||||
? format(filters.startDate, "PPP")
|
||||
: "Pick start date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.startDate || undefined}
|
||||
onSelect={(date) =>
|
||||
handleFilterChange("startDate", date || null)
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal p-2",
|
||||
!filters.endDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.endDate
|
||||
? format(filters.endDate, "PPP")
|
||||
: "Pick end date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.endDate || undefined}
|
||||
onSelect={(date) =>
|
||||
handleFilterChange("endDate", date || null)
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
340
src/app/mgmt/dashboard/reports/simple-reports-table.tsx
Normal file
340
src/app/mgmt/dashboard/reports/simple-reports-table.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { format } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { EyeIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import api from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { getStatusVariant, truncateContent } from "@/lib/utils";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
import { ReportsFilters } from "./page";
|
||||
|
||||
interface SimpleReportsTableProps {
|
||||
filters: ReportsFilters;
|
||||
currentPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export function SimpleReportsTable({ filters, currentPage, onPageChange }: SimpleReportsTableProps) {
|
||||
// Build query parameters from filters
|
||||
const buildQueryParams = () => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.adType) params.set("adType", filters.adType);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
if (filters.state) params.set("state", filters.state);
|
||||
if (filters.city) params.set("city", filters.city);
|
||||
if (filters.userType) params.set("userType", filters.userType);
|
||||
if (filters.mainCategory) params.set("mainCategoryId", filters.mainCategory);
|
||||
if (filters.categoryOne) params.set("categoryOneId", filters.categoryOne);
|
||||
if (filters.categoryTwo) params.set("categoryTwoId", filters.categoryTwo);
|
||||
if (filters.categoryThree) params.set("categoryThreeId", filters.categoryThree);
|
||||
|
||||
params.set("page", currentPage.toString());
|
||||
params.set("limit", ITEMS_PER_PAGE.toString());
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
// Check if we have any meaningful filters to show data
|
||||
const hasActiveFilters = Boolean(
|
||||
filters.adType || filters.status || filters.startDate ||
|
||||
filters.endDate ||
|
||||
filters.state ||
|
||||
filters.city ||
|
||||
filters.userType ||
|
||||
filters.mainCategory
|
||||
);
|
||||
|
||||
// Define the return type for the query
|
||||
interface QueryResult {
|
||||
items: any[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// Fetch ads data
|
||||
const { data, isLoading, error } = useQuery<QueryResult>({
|
||||
queryKey: ["simple-reports", filters, currentPage],
|
||||
queryFn: async (): Promise<QueryResult> => {
|
||||
const queryParams = buildQueryParams();
|
||||
const { data } = await api.get(`/reports/filtered-ads?${queryParams}`);
|
||||
|
||||
// API returns AdvancedFilterResponse object
|
||||
return {
|
||||
items: data.data,
|
||||
total: data.totalCount,
|
||||
totalPages: data.totalPages,
|
||||
};
|
||||
},
|
||||
enabled: hasActiveFilters,
|
||||
});
|
||||
|
||||
// Get the appropriate view link based on ad type
|
||||
const getViewLink = (ad: any) => {
|
||||
switch (filters.adType) {
|
||||
case AdType.LINE:
|
||||
return `/mgmt/dashboard/review-ads/line/view/${ad.id}`;
|
||||
case AdType.POSTER:
|
||||
return `/mgmt/dashboard/review-ads/poster/view/${ad.id}`;
|
||||
case AdType.VIDEO:
|
||||
return `/mgmt/dashboard/review-ads/video/view/${ad.id}`;
|
||||
default:
|
||||
// Try to detect ad type from ad properties
|
||||
if (ad.content !== undefined) return `/mgmt/dashboard/review-ads/line/view/${ad.id}`;
|
||||
if (ad.image && ad.dates) return `/mgmt/dashboard/review-ads/video/view/${ad.id}`;
|
||||
if (ad.image) return `/mgmt/dashboard/review-ads/poster/view/${ad.id}`;
|
||||
return `/mgmt/dashboard/review-ads/line/view/${ad.id}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the appropriate image based on ad type
|
||||
const getAdImage = (ad: any) => {
|
||||
if (filters.adType === AdType.LINE) {
|
||||
return ad.images && ad.images.length > 0 ? ad.images[0] : null;
|
||||
} else if (filters.adType === AdType.POSTER || filters.adType === AdType.VIDEO) {
|
||||
return ad.image;
|
||||
}
|
||||
|
||||
// Auto-detect
|
||||
if (ad.images && ad.images.length > 0) return ad.images[0];
|
||||
if (ad.image) return ad.image;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Get ad content for display
|
||||
const getAdContent = (ad: any) => {
|
||||
if (ad.content) return truncateContent(ad.content, 100);
|
||||
if (filters.adType === AdType.POSTER) return "Poster Advertisement";
|
||||
if (filters.adType === AdType.VIDEO) return "Video Advertisement";
|
||||
return "No content available";
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePreviousPage = () => {
|
||||
if (currentPage > 1) {
|
||||
onPageChange(currentPage - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (data && currentPage < data.totalPages) {
|
||||
onPageChange(currentPage + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// No filters applied
|
||||
if (!hasActiveFilters) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No Filters Applied</CardTitle>
|
||||
<CardDescription>
|
||||
Please apply at least one filter above to view ads data. Start by selecting an ad type.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Error loading ads data. Please try again or contact support if the issue persists.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// No results
|
||||
if (!data || !data.items || data.items.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No Results Found</CardTitle>
|
||||
<CardDescription>
|
||||
No ads match your current filter criteria. Try adjusting your filters.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Results Table */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="text-2xl font-bold">Results ({data.total})</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
Page {currentPage} of {data.totalPages} • {data.total} total results
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="p-4">Seq #</TableHead>
|
||||
<TableHead className="p-4">Content</TableHead>
|
||||
<TableHead className="p-4">Status</TableHead>
|
||||
<TableHead className="p-4">Categories</TableHead>
|
||||
<TableHead className="p-4">Location</TableHead>
|
||||
<TableHead className="p-4">Customer</TableHead>
|
||||
<TableHead className="p-4">Created</TableHead>
|
||||
<TableHead className="p-4">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((ad: any) => {
|
||||
const image = getAdImage(ad);
|
||||
|
||||
return (
|
||||
<TableRow key={ad.id}>
|
||||
<TableCell className="font-medium p-4">
|
||||
{ad.sequenceNumber}
|
||||
</TableCell>
|
||||
<TableCell className="p-4">
|
||||
<div className="flex items-center gap-3 max-w-xs">
|
||||
{image && (
|
||||
<div className="relative h-12 w-12 overflow-hidden rounded-md flex-shrink-0">
|
||||
<Image
|
||||
src={`/api/images?imageName=${image.fileName}`}
|
||||
alt="Ad image"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="line-clamp-2 text-sm">
|
||||
{getAdContent(ad)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-4">
|
||||
<Badge variant={getStatusVariant(ad.status) as any}>
|
||||
{ad.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="p-4">
|
||||
<div className="space-y-1 max-w-xs">
|
||||
<div className="text-sm font-medium">{ad.mainCategory?.name}</div>
|
||||
{(ad.categoryOne?.name || ad.categoryTwo?.name || ad.categoryThree?.name) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[ad.categoryOne?.name, ad.categoryTwo?.name, ad.categoryThree?.name]
|
||||
.filter(Boolean)
|
||||
.join(" → ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-4">
|
||||
<div className="text-sm">
|
||||
<div>{ad.city || "N/A"}</div>
|
||||
<div className="text-xs text-muted-foreground">{ad.state || "N/A"}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-4">
|
||||
<div className="text-sm max-w-xs">
|
||||
<div className="font-medium">{ad.customer?.user?.name || "Unknown"}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{ad.customer?.user?.email || ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-4">
|
||||
<div className="text-sm">
|
||||
{format(new Date(ad.created_at), "MMM dd, yyyy")}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{format(new Date(ad.created_at), "hh:mm a")}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-4">
|
||||
<Link href={getViewLink(ad)}>
|
||||
<Button variant="outline" size="sm">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Simple Pagination */}
|
||||
{data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={currentPage <= 1}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {data.totalPages}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage >= data.totalPages}
|
||||
className="flex items-center gap-2 px-4 py-2"
|
||||
>
|
||||
Next
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
475
src/app/mgmt/dashboard/reports/user-reports.tsx
Normal file
475
src/app/mgmt/dashboard/reports/user-reports.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, Download, Users, UserCheck, TrendingUp, Activity } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import api from "@/lib/api";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from "recharts";
|
||||
|
||||
interface UserReportFilters {
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
period: "daily" | "weekly" | "monthly";
|
||||
}
|
||||
|
||||
export function UserReports() {
|
||||
const [filters, setFilters] = useState<UserReportFilters>({
|
||||
startDate: new Date(new Date().setMonth(new Date().getMonth() - 1)),
|
||||
endDate: new Date(),
|
||||
period: "daily"
|
||||
});
|
||||
|
||||
// User Registration Report
|
||||
const { data: registrationData, isLoading: registrationLoading } = useQuery({
|
||||
queryKey: ["user-registrations", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
params.set("period", filters.period);
|
||||
|
||||
const { data } = await api.get(`/reports/users/registrations?${params}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!(filters.startDate && filters.endDate)
|
||||
});
|
||||
|
||||
// Active vs Inactive Users
|
||||
const { data: activeInactiveData, isLoading: activeInactiveLoading } = useQuery({
|
||||
queryKey: ["active-inactive-users"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/reports/users/active-vs-inactive");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
// User Login Activity
|
||||
const { data: loginActivityData, isLoading: loginActivityLoading } = useQuery({
|
||||
queryKey: ["user-login-activity", filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
params.set("period", filters.period);
|
||||
|
||||
const { data } = await api.get(`/reports/users/login-activity?${params}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!(filters.startDate && filters.endDate)
|
||||
});
|
||||
|
||||
// User Views by Category
|
||||
const { data: viewsByCategoryData, isLoading: viewsByCategoryLoading } = useQuery({
|
||||
queryKey: ["user-views-by-category"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get("/reports/users/views-by-category");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
const handleFilterChange = (key: keyof UserReportFilters, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const exportData = async (reportType: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.startDate) params.set("startDate", format(filters.startDate, "yyyy-MM-dd"));
|
||||
if (filters.endDate) params.set("endDate", format(filters.endDate, "yyyy-MM-dd"));
|
||||
params.set("format", "csv");
|
||||
params.set("reportType", reportType);
|
||||
|
||||
const response = await api.get(`/reports/export?${params}`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${reportType}-${format(new Date(), 'yyyy-MM-dd')}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
User Analytics Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure date ranges and grouping for user reports
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Start Date</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!filters.startDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.startDate ? format(filters.startDate, "PPP") : "Pick start date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.startDate || undefined}
|
||||
onSelect={(date) => handleFilterChange("startDate", date || null)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">End Date</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!filters.endDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{filters.endDate ? format(filters.endDate, "PPP") : "Pick end date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={filters.endDate || undefined}
|
||||
onSelect={(date) => handleFilterChange("endDate", date || null)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Period</label>
|
||||
<Select value={filters.period} onValueChange={(value: "daily" | "weekly" | "monthly") => handleFilterChange("period", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Actions</label>
|
||||
<Button
|
||||
onClick={() => exportData("user-registrations")}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeInactiveLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{activeInactiveData ? activeInactiveData.active + activeInactiveData.inactive : 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Registered users on platform
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
|
||||
<UserCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeInactiveLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{activeInactiveData?.active || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeInactiveData?.percentage?.active || 0}% of total users
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Growth Rate</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{registrationLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
+{registrationData?.summary?.growth || 0}%
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Registration growth
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Daily Active</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loginActivityLoading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{loginActivityData?.summary?.avgDailyActive || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Average daily active users
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* User Registration Trends */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Registration Trends</CardTitle>
|
||||
<CardDescription>
|
||||
New user registrations over time
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{registrationLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : registrationData?.data ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={registrationData.data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="period" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="count" stroke="#8884d8" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="activeCount" stroke="#82ca9d" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No registration data available for the selected period.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Active vs Inactive Users */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Status Distribution</CardTitle>
|
||||
<CardDescription>
|
||||
Active vs inactive user breakdown
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeInactiveLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : activeInactiveData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: "Active", value: activeInactiveData.active, color: "#00C49F" },
|
||||
{ name: "Inactive", value: activeInactiveData.inactive, color: "#FF8042" }
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{[{ color: "#00C49F" }, { color: "#FF8042" }].map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No user status data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Login Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Login Activity</CardTitle>
|
||||
<CardDescription>
|
||||
User login patterns over time
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loginActivityLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : loginActivityData?.data ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={loginActivityData.data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="period" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" fill="#8884d8" />
|
||||
<Bar dataKey="activeCount" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No login activity data available for the selected period.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Views by Category */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Views by Category</CardTitle>
|
||||
<CardDescription>
|
||||
Most popular categories by estimated views
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{viewsByCategoryLoading ? (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : viewsByCategoryData ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={viewsByCategoryData.slice(0, 10)}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="categoryName" angle={-45} textAnchor="end" height={100} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="estimatedViews" fill="#0088FE" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No category views data available.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Registration Data */}
|
||||
{registrationData?.data && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detailed Registration Statistics</CardTitle>
|
||||
<CardDescription>
|
||||
Breakdown of user registrations by period and demographics
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">Period</th>
|
||||
<th className="text-left p-2">Total</th>
|
||||
<th className="text-left p-2">Active</th>
|
||||
<th className="text-left p-2">By Role</th>
|
||||
<th className="text-left p-2">By Gender</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{registrationData.data.map((item: any, index: number) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="p-2 font-medium">{item.period}</td>
|
||||
<td className="p-2">{item.count}</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="secondary">{item.activeCount}</Badge>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="text-xs space-y-1">
|
||||
{item.byRole && Object.entries(item.byRole).map(([role, count]: [string, any]) => (
|
||||
<div key={role}>{role}: {count}</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="text-xs space-y-1">
|
||||
{item.byGender && Object.entries(item.byGender).map(([gender, count]: [string, any]) => (
|
||||
<div key={gender}>{gender}: {count}</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ import {
|
||||
Phone,
|
||||
Tag,
|
||||
User,
|
||||
Layout,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -119,7 +121,8 @@ export default function ViewLineAd() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push("/mgmt/dashboard/review-ads/line")}
|
||||
// onClick={() => router.push("/mgmt/dashboard/review-ads/line")}
|
||||
onClick={() => router.back()}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3 mr-1" />
|
||||
@@ -235,6 +238,21 @@ export default function ViewLineAd() {
|
||||
<span className="text-gray-600">Posted by:</span>
|
||||
<span className="font-medium">{ad.postedBy}</span>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<Layout className="h-3 w-3" />
|
||||
Position Information
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs bg-blue-50 px-2 py-1 rounded border border-blue-200">
|
||||
<Monitor className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-blue-700 font-medium">
|
||||
{ad.pageType === 'HOME' ? 'Home Page' : 'Category Pages'} - Line Ads Section
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ import api from "@/lib/api";
|
||||
import { AdStatus } from "@/lib/enum/ad-status";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
import { PostedBy } from "@/lib/enum/posted-by";
|
||||
import { Position } from "@/lib/enum/position";
|
||||
import { Role } from "@/lib/enum/roles.enum";
|
||||
import { PageType } from "@/lib/enum/page-type";
|
||||
import type { MainCategory, SubCategory } from "@/lib/types/category";
|
||||
import { PosterAd } from "@/lib/types/posterAd";
|
||||
import type { Media } from "@/lib/types/media";
|
||||
@@ -66,11 +66,15 @@ import {
|
||||
CreditCard,
|
||||
UserIcon,
|
||||
Phone,
|
||||
Layout,
|
||||
Monitor,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAdNavigation } from "@/hooks/useAdNavigation";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { CitySelect, StateSelect } from "react-country-state-city";
|
||||
import "react-country-state-city/dist/react-country-state-city.css";
|
||||
import Zoom from "react-medium-image-zoom";
|
||||
@@ -79,10 +83,13 @@ import { toast } from "sonner";
|
||||
export default function EditPosterAd() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { goBack } = useAdNavigation(AdType.POSTER, params.id as string);
|
||||
const queryClient = useQueryClient();
|
||||
const [comment, setComment] = useState("");
|
||||
const [selectedStatus, setSelectedStatus] = useState<AdStatus | "">("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isFormInitialized, setIsFormInitialized] = useState(false);
|
||||
const prevAdId = useRef<string | undefined>(undefined);
|
||||
|
||||
// Fetch current user
|
||||
const { data: currentUser } = useQuery<User>({
|
||||
@@ -98,7 +105,9 @@ export default function EditPosterAd() {
|
||||
|
||||
// Form state for ad details
|
||||
const [postedBy, setPostedBy] = useState("");
|
||||
const [position, setPosition] = useState("");
|
||||
const [pageType, setPageType] = useState<PageType>(PageType.HOME);
|
||||
const [side, setSide] = useState("");
|
||||
const [positionNumber, setPositionNumber] = useState<number>(1);
|
||||
const [mainCategoryId, setMainCategoryId] = useState("");
|
||||
const [categoryOneId, setCategoryOneId] = useState("");
|
||||
const [categoryTwoId, setCategoryTwoId] = useState("");
|
||||
@@ -138,6 +147,7 @@ export default function EditPosterAd() {
|
||||
data: ad,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: refetchAd,
|
||||
} = useQuery({
|
||||
queryKey: ["posterAd", params.id],
|
||||
queryFn: async () => {
|
||||
@@ -230,7 +240,7 @@ export default function EditPosterAd() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Ad details updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["posterAd", params.id] });
|
||||
refetchAd();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update ad details");
|
||||
@@ -254,7 +264,7 @@ export default function EditPosterAd() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Payment details updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["posterAd", params.id] });
|
||||
refetchAd();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update payment details");
|
||||
@@ -275,8 +285,10 @@ export default function EditPosterAd() {
|
||||
toast.success("Ad status updated successfully");
|
||||
setComment("");
|
||||
setSelectedStatus("");
|
||||
queryClient.invalidateQueries({ queryKey: ["posterAd", params.id] });
|
||||
refetchAd();
|
||||
queryClient.invalidateQueries({ queryKey: ["adComments", params.id] });
|
||||
// Navigate back to the source page after status update
|
||||
setTimeout(() => goBack(), 1000);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update ad status");
|
||||
@@ -286,50 +298,65 @@ export default function EditPosterAd() {
|
||||
|
||||
// Populate form with ad data when it's loaded
|
||||
useEffect(() => {
|
||||
if (ad) {
|
||||
setPostedBy(ad.postedBy);
|
||||
console.log("Pos", ad.position);
|
||||
setPosition(ad.position);
|
||||
if (!ad) return;
|
||||
// Reset initialization if ad id changes
|
||||
if (prevAdId.current !== ad.id) {
|
||||
setIsFormInitialized(false);
|
||||
prevAdId.current = ad.id;
|
||||
}
|
||||
|
||||
// Set categories
|
||||
setMainCategoryId(ad.mainCategory.id);
|
||||
setCategoryOneId(ad.categoryOne?.id || "");
|
||||
setCategoryTwoId(ad.categoryTwo?.id || "");
|
||||
setCategoryThreeId(ad.categoryThree?.id || "");
|
||||
if (isFormInitialized) return;
|
||||
|
||||
// Set location
|
||||
setStateValue(ad.state);
|
||||
setStateId(ad.sid);
|
||||
setCityValue(ad.city);
|
||||
setCityId(ad.cid);
|
||||
setPostedBy(ad.postedBy);
|
||||
console.log("Pos", ad.position);
|
||||
|
||||
// Set position details if available
|
||||
if (ad.position) {
|
||||
setPageType(ad.position.pageType === 'HOME' ? PageType.HOME : PageType.CATEGORY);
|
||||
setSide(ad.position.side || "");
|
||||
setPositionNumber(ad.position.position || 1);
|
||||
}
|
||||
|
||||
// Set dates
|
||||
const parsedDates = ad.dates.map((d) => new Date(d));
|
||||
setSelectedDates(parsedDates);
|
||||
setFormattedDates(ad.dates);
|
||||
// Set categories
|
||||
setMainCategoryId(ad.mainCategory.id);
|
||||
setCategoryOneId(ad.categoryOne?.id || "");
|
||||
setCategoryTwoId(ad.categoryTwo?.id || "");
|
||||
setCategoryThreeId(ad.categoryThree?.id || "");
|
||||
|
||||
const image = {
|
||||
id: ad.image.id,
|
||||
url: ad.image.fileName,
|
||||
};
|
||||
setUploadedImage(image);
|
||||
// Set location
|
||||
setStateValue(ad.state);
|
||||
setStateId(ad.sid);
|
||||
setCityValue(ad.city);
|
||||
setCityId(ad.cid);
|
||||
|
||||
// Set payment details if available
|
||||
if (ad.payment) {
|
||||
setPaymentMethod(ad.payment.method);
|
||||
setPaymentAmount(ad.payment.amount);
|
||||
setPaymentDetails(ad.payment.details || "");
|
||||
// Set dates
|
||||
const parsedDates = ad.dates.map((d) => new Date(d));
|
||||
setSelectedDates(parsedDates);
|
||||
setFormattedDates(ad.dates);
|
||||
|
||||
if (ad.payment.proof) {
|
||||
setPaymentProofId(ad.payment.proof.id);
|
||||
setPaymentProofImage({
|
||||
id: ad.payment.proof.id,
|
||||
url: `/api/images?imageName=${ad.payment.proof.fileName}`,
|
||||
});
|
||||
}
|
||||
const image = {
|
||||
id: ad.image.id,
|
||||
url: ad.image.fileName,
|
||||
};
|
||||
setUploadedImage(image);
|
||||
|
||||
// Set payment details if available
|
||||
if (ad.payment) {
|
||||
setPaymentMethod(ad.payment.method);
|
||||
setPaymentAmount(ad.payment.amount);
|
||||
setPaymentDetails(ad.payment.details || "");
|
||||
|
||||
if (ad.payment.proof) {
|
||||
setPaymentProofId(ad.payment.proof.id);
|
||||
setPaymentProofImage({
|
||||
id: ad.payment.proof.id,
|
||||
url: `/api/images?imageName=${ad.payment.proof.fileName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [ad]);
|
||||
|
||||
setIsFormInitialized(true);
|
||||
}, [ad, isFormInitialized]);
|
||||
|
||||
// Update subcategories when main category changes
|
||||
useEffect(() => {
|
||||
@@ -380,6 +407,13 @@ export default function EditPosterAd() {
|
||||
}
|
||||
}, [categoryTwoId, level2Categories, ad]);
|
||||
|
||||
// Auto-set position to 1 when CENTER_TOP or CENTER_BOTTOM is selected
|
||||
useEffect(() => {
|
||||
if (side === "CENTER_TOP" || side === "CENTER_BOTTOM") {
|
||||
setPositionNumber(1);
|
||||
}
|
||||
}, [side]);
|
||||
|
||||
// Handle state selection
|
||||
const handleStateChange = (stateObj: any) => {
|
||||
setState(stateObj);
|
||||
@@ -509,8 +543,8 @@ export default function EditPosterAd() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!position) {
|
||||
toast.error("Position is required");
|
||||
if (!side || !positionNumber) {
|
||||
toast.error("Position side and number are required");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -526,7 +560,12 @@ export default function EditPosterAd() {
|
||||
cid: cityId,
|
||||
dates: formattedDates,
|
||||
postedBy,
|
||||
position,
|
||||
position: {
|
||||
adType: 'POSTER' as const,
|
||||
pageType,
|
||||
side,
|
||||
position: positionNumber,
|
||||
},
|
||||
};
|
||||
|
||||
await updateAd(adData);
|
||||
@@ -582,8 +621,31 @@ export default function EditPosterAd() {
|
||||
});
|
||||
};
|
||||
|
||||
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
setIsSavingAll(true);
|
||||
try {
|
||||
await handleAdUpdate();
|
||||
await handlePaymentUpdate();
|
||||
toast.success("All changes saved successfully");
|
||||
// Navigate back to source page after save
|
||||
setTimeout(() => goBack(), 1000);
|
||||
} catch (error) {
|
||||
toast.error("Failed to save changes");
|
||||
} finally {
|
||||
setIsSavingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
if (
|
||||
isLoading ||
|
||||
isLoadingCategories ||
|
||||
isLoadingComments ||
|
||||
!currentUser ||
|
||||
!isFormInitialized
|
||||
) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center">
|
||||
<div className="space-y-4 text-center">
|
||||
@@ -604,9 +666,7 @@ export default function EditPosterAd() {
|
||||
<h3 className="text-lg font-semibold">Failed to load ad details</h3>
|
||||
<p className="text-gray-600">Please try again later</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push("/mgmt/dashboard/review-ads/poster")}
|
||||
>
|
||||
<Button onClick={goBack}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Poster Ads
|
||||
</Button>
|
||||
@@ -631,15 +691,11 @@ export default function EditPosterAd() {
|
||||
return status !== ad.status;
|
||||
});
|
||||
|
||||
// Determine image container size based on position
|
||||
const isCenterPosition =
|
||||
position === Position.CENTER_TOP || position === Position.CENTER_BOTTOM;
|
||||
const imageContainerClass = isCenterPosition
|
||||
? "relative h-56 w-96 max-w-full border border-gray-300 border-dashed rounded-md overflow-hidden bg-gray-50"
|
||||
: "relative aspect-[3/4] border border-gray-300 border-dashed rounded-md overflow-hidden bg-gray-50";
|
||||
// Use consistent image container class
|
||||
const imageContainerClass = "relative aspect-[3/4] border border-gray-300 border-dashed rounded-md overflow-hidden bg-gray-50";
|
||||
|
||||
return (
|
||||
<div className=" bg-gray-50">
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto p-4 pt-0">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-sm border mb-4 p-3">
|
||||
@@ -648,7 +704,7 @@ export default function EditPosterAd() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push("/mgmt/dashboard/review-ads/poster")}
|
||||
onClick={goBack}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3 mr-1" />
|
||||
@@ -668,7 +724,7 @@ export default function EditPosterAd() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-3 h-[calc(100vh-120px)]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-3">
|
||||
{/* Left Column - Poster Image & Categories */}
|
||||
<div className="space-y-3">
|
||||
{/* Poster Image */}
|
||||
@@ -738,6 +794,33 @@ export default function EditPosterAd() {
|
||||
<span className="text-gray-600">Posted by:</span>
|
||||
<span className="font-medium">{ad.postedBy}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-600 flex items-center gap-1">
|
||||
<Layout className="h-3 w-3" />
|
||||
Current Position
|
||||
</Label>
|
||||
{ad.position ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs bg-blue-50 px-2 py-1 rounded border border-blue-200">
|
||||
<Monitor className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-blue-700 font-medium">
|
||||
{ad.position.pageType === 'HOME' ? 'Home Page' : 'Category Pages'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs bg-green-50 px-2 py-1 rounded border border-green-200">
|
||||
<Layers className="h-3 w-3 text-green-600" />
|
||||
<span className="text-green-700 font-medium">
|
||||
{ad.position.side?.replace('_', ' ')} - Position {ad.position.position}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs bg-gray-50 px-2 py-1 rounded border border-gray-200">
|
||||
<span className="text-gray-600">No position assigned</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -836,26 +919,6 @@ export default function EditPosterAd() {
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="pt-0 px-4 pb-4">
|
||||
<Button
|
||||
onClick={handleAdUpdate}
|
||||
disabled={isUpdatingAd}
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs"
|
||||
>
|
||||
{isUpdatingAd ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
Save Details
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -938,19 +1001,89 @@ export default function EditPosterAd() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-600">Position</Label>
|
||||
<Select value={position} onValueChange={setPosition}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select position" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(Position).map((pos) => (
|
||||
<SelectItem key={pos} value={pos}>
|
||||
{pos.replace(/_/g, " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="text-xs text-gray-600 flex items-center gap-1">
|
||||
<Layout className="h-3 w-3" />
|
||||
Update Position
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">Page Type</Label>
|
||||
<Select value={pageType} onValueChange={(value) => setPageType(value as PageType)}>
|
||||
<SelectTrigger className="h-7 text-sm">
|
||||
<SelectValue placeholder="Select page type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HOME">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-3 w-3" />
|
||||
Home Page
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="CATEGORY">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="h-3 w-3" />
|
||||
Category Pages
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">Side</Label>
|
||||
<Select value={side} onValueChange={setSide}>
|
||||
<SelectTrigger className="h-7 text-sm">
|
||||
<SelectValue placeholder="Select side" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LEFT_SIDE">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-3 w-3" />
|
||||
Left Side
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="RIGHT_SIDE">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-3 w-3" />
|
||||
Right Side
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="CENTER_TOP">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-3 w-3" />
|
||||
Center Top
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="CENTER_BOTTOM">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-3 w-3" />
|
||||
Center Bottom
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Only show Position Number field for LEFT_SIDE and RIGHT_SIDE */}
|
||||
{(side === "LEFT_SIDE" || side === "RIGHT_SIDE") && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">Position Number</Label>
|
||||
<Select value={positionNumber.toString()} onValueChange={(value) => setPositionNumber(Number(value))}>
|
||||
<SelectTrigger className="h-7 text-sm">
|
||||
<SelectValue placeholder="Select position number" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 6 }, (_, i) => i + 1).map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-3 w-3" />
|
||||
Position {num}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1141,26 +1274,6 @@ export default function EditPosterAd() {
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-0 px-4 pb-4">
|
||||
<Button
|
||||
onClick={handlePaymentUpdate}
|
||||
disabled={isUpdatingPayment}
|
||||
size="sm"
|
||||
className="w-full h-8 text-xs"
|
||||
>
|
||||
{isUpdatingPayment ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
Save Payment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1292,6 +1405,30 @@ export default function EditPosterAd() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom action bar */}
|
||||
<div className="flex justify-between items-center mt-6 pb-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={goBack}
|
||||
disabled={isSavingAll || isUpdatingAd || isUpdatingPayment}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
disabled={isSavingAll || isUpdatingAd || isUpdatingPayment}
|
||||
>
|
||||
{isSavingAll || isUpdatingAd || isUpdatingPayment ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" /> Save All
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
Phone,
|
||||
Tag,
|
||||
User,
|
||||
Layout,
|
||||
Monitor,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -240,6 +243,35 @@ export default function ViewLineAd() {
|
||||
<span className="text-gray-600">Posted by:</span>
|
||||
<span className="font-medium">{ad.postedBy}</span>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<Layout className="h-3 w-3" />
|
||||
Position Information
|
||||
</div>
|
||||
{ad.position ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs bg-blue-50 px-2 py-1 rounded border border-blue-200">
|
||||
<Monitor className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-blue-700 font-medium">
|
||||
{ad.position.pageType === 'HOME' ? 'Home Page' : 'Category Pages'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs bg-green-50 px-2 py-1 rounded border border-green-200">
|
||||
<Layers className="h-3 w-3 text-green-600" />
|
||||
<span className="text-green-700 font-medium">
|
||||
{ad.position.side?.replace('_', ' ')} - Position {ad.position.position}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs bg-gray-50 px-2 py-1 rounded border border-gray-200">
|
||||
<span className="text-gray-600">No position assigned</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,13 @@ export const columns: ColumnDef<VideoAd>[] = [
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <div className="">{row.original.position.replace("_", " ")}</div>;
|
||||
return (
|
||||
<div className="">
|
||||
{row.original.position.pageType}
|
||||
{/* {row.original.position.side.replace("_", " ")} -{" "}
|
||||
{row.original.position.position} */}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,9 @@ import {
|
||||
Phone,
|
||||
Tag,
|
||||
User,
|
||||
Layout,
|
||||
Monitor,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -237,6 +240,37 @@ export default function ViewLineAd() {
|
||||
<span className="text-gray-600">Posted by:</span>
|
||||
<span className="font-medium">{ad.postedBy}</span>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Simple Position Information */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<Layout className="h-3 w-3" />
|
||||
Position Information
|
||||
</div>
|
||||
|
||||
{ad.position ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs bg-blue-50 px-2 py-1 rounded border border-blue-200">
|
||||
<Monitor className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-blue-700 font-medium">
|
||||
Page Type: {ad.position.pageType === "HOME" ? "Home Page" : "Category Pages"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs bg-green-50 px-2 py-1 rounded border border-green-200">
|
||||
<Layers className="h-3 w-3 text-green-600" />
|
||||
<span className="text-green-700 font-medium">
|
||||
Side: {ad.position.side.replace("_", " ")} - Position #{ad.position.position}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-xs bg-gray-50 px-2 py-1 rounded border border-gray-200">
|
||||
<span className="text-gray-600">No position assigned</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
186
src/app/mgmt/dashboard/users/[id]/page.tsx
Normal file
186
src/app/mgmt/dashboard/users/[id]/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import api from "@/lib/api";
|
||||
import { Role } from "@/lib/enum/roles.enum";
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const userId = params?.id as string;
|
||||
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["user", userId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/users/${userId}`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<Skeleton className="h-8 w-64 mb-4" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-2">User Details</h1>
|
||||
<p className="text-destructive">Unable to load user details.</p>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => router.push("/mgmt/dashboard/profile")}
|
||||
>
|
||||
Go to My Profile
|
||||
</Button>
|
||||
</div>
|
||||
<Card className="pb-5">
|
||||
<CardHeader>
|
||||
<CardTitle>User Details</CardTitle>
|
||||
<CardDescription>View all information for this user.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
Name
|
||||
</span>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
Email
|
||||
</span>
|
||||
<span className="font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
Phone Number
|
||||
</span>
|
||||
<span className="font-medium">{user.phone_number || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
Status
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
user.isActive
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}
|
||||
>
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
Role
|
||||
</span>
|
||||
<Badge variant="outline" className="bg-gray-100 text-gray-800">
|
||||
{user.role?.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
Type
|
||||
</span>
|
||||
{user.admin ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-purple-100 text-purple-800"
|
||||
>
|
||||
Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-orange-100 text-orange-800"
|
||||
>
|
||||
Customer
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user.customer && (
|
||||
<>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
Country
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{user.customer.country || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
State
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{user.customer.state || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
City
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{user.customer.city || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-muted-foreground mb-1">
|
||||
Gender
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{user.customer.gender === 1
|
||||
? "Male"
|
||||
: user.customer.gender === 2
|
||||
? "Female"
|
||||
: "Other"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { Role } from "@/lib/enum/roles.enum";
|
||||
import { CreateUserDialog } from "@/components/mgmt/create-user-dialog";
|
||||
import { ActivateUserDialog } from "@/components/mgmt/user-activate-dialog";
|
||||
import { DeactivateUserDialog } from "@/components/mgmt/user-deactivate-dialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -68,6 +69,8 @@ export default function UsersPage() {
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => {
|
||||
@@ -231,6 +234,13 @@ export default function UsersPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(`/mgmt/dashboard/users/${user.id}`)
|
||||
}
|
||||
>
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
{user.isActive ? (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
275
src/app/test-ad-slots/page.tsx
Normal file
275
src/app/test-ad-slots/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronLeft, ChevronRight, Eye, Map } from "lucide-react";
|
||||
|
||||
// Mock data for testing
|
||||
const mockSummary = {
|
||||
totalSlots: 24,
|
||||
occupiedSlots: 18,
|
||||
availableSlots: 6,
|
||||
categories: 8,
|
||||
};
|
||||
|
||||
const mockSlots = Array.from({ length: 24 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `${["Home Page", "Category Page", "Search Page"][i % 3]} - ${["Left Sidebar", "Right Sidebar", "Header Banner", "Footer Banner"][i % 4]}`,
|
||||
category: ["Electronics", "Real Estate", "Fashion", "Automotive", "Health", "Services", "Food", "Education"][i % 8],
|
||||
isOccupied: i < 18,
|
||||
activeAds: i < 18 ? Math.floor(Math.random() * 5) + 1 : 0,
|
||||
pageType: ["Home Page", "Category Page", "Search Page"][i % 3],
|
||||
position: ["Left Sidebar", "Right Sidebar", "Header Banner", "Footer Banner"][i % 4],
|
||||
adType: ["LINE", "POSTER", "VIDEO"][i % 3],
|
||||
}));
|
||||
|
||||
const mockCategories = ["All Categories", "Electronics", "Real Estate", "Fashion", "Automotive", "Health", "Services", "Food", "Education"];
|
||||
|
||||
export default function TestAdSlotsOverview() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedCategory, setSelectedCategory] = useState("All Categories");
|
||||
const itemsPerPage = 8;
|
||||
|
||||
// Filter slots by category
|
||||
const filteredSlots = selectedCategory === "All Categories"
|
||||
? mockSlots
|
||||
: mockSlots.filter(slot => slot.category === selectedCategory);
|
||||
|
||||
// Calculate pagination
|
||||
const totalItems = filteredSlots.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const currentSlots = filteredSlots.slice(startIndex, endIndex);
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category);
|
||||
setCurrentPage(1); // Reset to first page when filtering
|
||||
};
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
setCurrentPage(Math.max(1, Math.min(page, totalPages)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-5 px-10">
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
{/* Header */}
|
||||
<div className="col-span-12">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Ad Slots Overview</h1>
|
||||
<p className="text-gray-600">Manage and monitor advertisement slot occupancy across all pages</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="col-span-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Slots</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{mockSummary.totalSlots}</p>
|
||||
</div>
|
||||
<Map className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-green-500">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Occupied</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{mockSummary.occupiedSlots}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<div className="h-4 w-4 bg-green-500 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-orange-500">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Available</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{mockSummary.availableSlots}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<div className="h-4 w-4 bg-orange-500 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-l-4 border-l-purple-500">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Categories</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{mockSummary.categories}</p>
|
||||
</div>
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<div className="h-4 w-4 bg-purple-500 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="col-span-12">
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Filter by Category
|
||||
</label>
|
||||
<Select value={selectedCategory} onValueChange={handleCategoryChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mockCategories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ad Slots Grid */}
|
||||
<div className="col-span-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-8">
|
||||
{currentSlots.map((slot) => (
|
||||
<Card key={slot.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-gray-900 truncate">
|
||||
Slot #{slot.id}
|
||||
</CardTitle>
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
slot.isOccupied
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}>
|
||||
{slot.isOccupied ? 'Occupied' : 'Available'}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Location</p>
|
||||
<p className="text-sm font-medium text-gray-900">{slot.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<p className="text-gray-500">Page</p>
|
||||
<p className="font-medium">{slot.pageType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Position</p>
|
||||
<p className="font-medium">{slot.position}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Category</p>
|
||||
<p className="font-medium">{slot.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Type</p>
|
||||
<p className="font-medium">{slot.adType}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slot.isOccupied && (
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500">Active Ads</p>
|
||||
<p className="text-lg font-bold text-blue-600">{slot.activeAds}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => alert(`View details for Slot #${slot.id}`)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="col-span-12">
|
||||
<div className="flex items-center justify-between bg-white px-6 py-4 border rounded-lg">
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<span>
|
||||
Showing {startIndex + 1} to {Math.min(endIndex, totalItems)} of {totalItems} ad slots
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex space-x-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
const pageNum = Math.max(1, currentPage - 2) + i;
|
||||
if (pageNum > totalPages) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => goToPage(pageNum)}
|
||||
className="w-10"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/verify-otp/page.tsx
Normal file
124
src/app/verify-otp/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { Loader2, LogOut } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import api from "@/lib/api";
|
||||
import { User } from "@/lib/types/user";
|
||||
import PhoneVerification from "@/components/forms/phone-verification";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function VerifyOtpPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Check current user authentication status
|
||||
const { data: user, isLoading: isCheckingAuth, error } = useQuery<User>({
|
||||
queryKey: ["user"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await api.get("/auth/profile");
|
||||
return data;
|
||||
} catch (error) {
|
||||
// User is not logged in
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth) {
|
||||
// If user is not logged in, redirect to home page to login
|
||||
if (error || !user) {
|
||||
toast.error("Please login first to verify your phone number");
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
|
||||
// If user is already verified, redirect to dashboard
|
||||
if (user.phone_verified) {
|
||||
const dashboardPath = user.role === "USER" ? "/dashboard" : "/mgmt/dashboard";
|
||||
router.push(dashboardPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [user, isCheckingAuth, error, router]);
|
||||
|
||||
// Handle successful verification
|
||||
const handleVerificationSuccess = () => {
|
||||
const dashboardPath = user?.role === "USER" ? "/dashboard" : "/mgmt/dashboard";
|
||||
toast.success("Phone number verified successfully!");
|
||||
router.push(dashboardPath);
|
||||
};
|
||||
|
||||
// Handle logout
|
||||
const { mutate: logout } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post("/auth/logout");
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Please login again to Continue");
|
||||
router.push("/");
|
||||
},
|
||||
onError: () => {
|
||||
// Even if logout fails, clear the frontend state
|
||||
router.push("/");
|
||||
}
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
// Handle back navigation
|
||||
const handleBack = () => {
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
if (isCheckingAuth) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">Checking authentication...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show verification form only if user exists and is not verified
|
||||
if (!user || user.phone_verified || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header with logout button */}
|
||||
<div className="flex justify-between items-center p-4 bg-white shadow-sm">
|
||||
<h1 className="text-lg font-semibold text-gray-900">Phone Verification</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex items-center justify-center min-h-[calc(100vh-80px)] p-4">
|
||||
<PhoneVerification
|
||||
phoneNumber={user.phone_number}
|
||||
onVerificationSuccess={handleVerificationSuccess}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function DashboardNavbar({
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b px-6 py-3">
|
||||
<header className="bg-white border-b px-6 py-3 lg:ml-64">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">PaisaAds</h1>
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
ChevronRight,
|
||||
Video,
|
||||
ImageIcon,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ManagementSidebarProps {
|
||||
@@ -34,6 +36,7 @@ interface MenuItem {
|
||||
|
||||
export default function DashboardSidebar({ pathname }: ManagementSidebarProps) {
|
||||
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
@@ -46,14 +49,14 @@ export default function DashboardSidebar({ pathname }: ManagementSidebarProps) {
|
||||
href: "/dashboard/ads",
|
||||
icon: CheckCircle,
|
||||
submenu: [
|
||||
{
|
||||
title: "Poster Ads",
|
||||
href: "/dashboard/my-ads/poster-ads",
|
||||
},
|
||||
{
|
||||
title: "Line Ads",
|
||||
href: "/dashboard/my-ads/line-ads",
|
||||
},
|
||||
{
|
||||
title: "Poster Ads",
|
||||
href: "/dashboard/my-ads/poster-ads",
|
||||
},
|
||||
{
|
||||
title: "Video Ads",
|
||||
href: "/dashboard/my-ads/video-ads",
|
||||
@@ -84,81 +87,117 @@ export default function DashboardSidebar({ pathname }: ManagementSidebarProps) {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-white border-r shadow-sm overflow-y-auto">
|
||||
<nav className="p-2">
|
||||
<ul className="space-y-1">
|
||||
{menuItems
|
||||
// .filter((item) => item.roles.includes(userRole))
|
||||
.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
||||
const isSubmenuOpen = openSubmenu === item.title;
|
||||
const handleMobileClose = () => {
|
||||
setIsMobileOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={item.title}>
|
||||
{hasSubmenu ? (
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleSubmenu(item.title)}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3 py-2 text-sm rounded-md hover:bg-gray-100",
|
||||
active && "bg-gray-100 font-medium"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-3" />
|
||||
<span className="flex-1 text-left">{item.title}</span>
|
||||
{isSubmenuOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{isSubmenuOpen && (
|
||||
<ul className="pl-9 space-y-1">
|
||||
{item.submenu?.map((subitem) => (
|
||||
<li key={subitem.title}>
|
||||
<Link
|
||||
href={subitem.href}
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 text-sm rounded-md hover:bg-gray-100",
|
||||
isActive(subitem.href) &&
|
||||
"bg-gray-100 font-medium"
|
||||
)}
|
||||
>
|
||||
{subitem.title === "Line Ads" && (
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{subitem.title === "Video Ads" && (
|
||||
<Video className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{subitem.title === "Poster Ads" && (
|
||||
<ImageIcon className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{subitem.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
const sidebarContent = (
|
||||
<nav className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-medium">Menu</h2>
|
||||
<button
|
||||
onClick={handleMobileClose}
|
||||
className="p-2 rounded hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const active = isActive(item.href);
|
||||
const hasSubmenu = item.submenu && item.submenu.length > 0;
|
||||
const isSubmenuOpen = openSubmenu === item.title;
|
||||
|
||||
return (
|
||||
<li key={item.title}>
|
||||
{hasSubmenu ? (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleSubmenu(item.title)}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3 py-2 text-sm",
|
||||
active && "font-medium"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-3" />
|
||||
<span className="flex-1 text-left">{item.title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 text-sm rounded-md hover:bg-gray-100",
|
||||
active && "bg-gray-100 font-medium"
|
||||
"h-4 w-4 transition-transform",
|
||||
isSubmenuOpen && "rotate-90"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-3" />
|
||||
{item.title}
|
||||
</Link>
|
||||
/>
|
||||
</button>
|
||||
{isSubmenuOpen && (
|
||||
<ul className="pl-6 space-y-1">
|
||||
{item.submenu?.map((subitem) => (
|
||||
<li key={subitem.title}>
|
||||
<Link
|
||||
href={subitem.href}
|
||||
onClick={handleMobileClose}
|
||||
className={cn(
|
||||
"block px-3 py-2 text-sm",
|
||||
isActive(subitem.href) && "font-medium"
|
||||
)}
|
||||
>
|
||||
{subitem.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={handleMobileClose}
|
||||
className={cn(
|
||||
"flex items-center px-3 py-2 text-sm",
|
||||
active && "font-medium"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-3" />
|
||||
{item.title}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(true)}
|
||||
className="lg:hidden fixed top-3 left-4 z-40 p-2 bg-white rounded-md shadow-md"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-40 bg-black bg-opacity-50"
|
||||
onClick={handleMobileClose}
|
||||
>
|
||||
<div
|
||||
className="fixed left-0 top-0 h-full w-64 bg-white shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:block w-64 bg-white border-r shadow-sm overflow-y-auto ">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import api from "@/lib/api";
|
||||
import {
|
||||
BarChart2,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
Eye,
|
||||
PauseCircle,
|
||||
XCircle,
|
||||
Image,
|
||||
List,
|
||||
Clock,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DashboardStats {
|
||||
totalAds: number;
|
||||
@@ -17,9 +28,44 @@ interface DashboardStats {
|
||||
PUBLISHED: number;
|
||||
PAUSED: number;
|
||||
};
|
||||
ads: any[];
|
||||
videoAds: number;
|
||||
posterAds: number;
|
||||
lineAds: number;
|
||||
}
|
||||
|
||||
// Fetch dashboard stats
|
||||
const statusMeta = [
|
||||
{
|
||||
label: "TOTAL",
|
||||
icon: BarChart2,
|
||||
color: "bg-primary/10 text-primary",
|
||||
},
|
||||
{
|
||||
label: "PUBLISHED",
|
||||
icon: CheckCircle2,
|
||||
color: "bg-green-100 text-green-800",
|
||||
},
|
||||
{
|
||||
label: "DRAFT",
|
||||
icon: FileText,
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
},
|
||||
{
|
||||
label: "IN REVIEW",
|
||||
icon: Eye,
|
||||
color: "bg-blue-100 text-blue-800",
|
||||
},
|
||||
{
|
||||
label: "PAUSED",
|
||||
icon: PauseCircle,
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
},
|
||||
{
|
||||
label: "REJECTED",
|
||||
icon: XCircle,
|
||||
color: "bg-red-100 text-red-800",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DashboardStats() {
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -32,9 +78,9 @@ export default function DashboardStats() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<Card key={i} className="shadow-md">
|
||||
<CardContent className="p-6">
|
||||
<Skeleton className="h-7 w-20 mb-2" />
|
||||
<Skeleton className="h-9 w-12" />
|
||||
@@ -45,52 +91,114 @@ export default function DashboardStats() {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate the IN REVIEW count (FOR_REVIEW + YET_TO_BE_PUBLISHED)
|
||||
const inReviewCount =
|
||||
(data?.statusCounts.FOR_REVIEW || 0) +
|
||||
(data?.statusCounts.YET_TO_BE_PUBLISHED || 0);
|
||||
|
||||
// Define the tiles to display
|
||||
// Count poster and line ads
|
||||
|
||||
// Last ad posted date
|
||||
const lastAdDate = data?.ads?.length
|
||||
? new Date(
|
||||
Math.max(...data.ads.map((ad) => new Date(ad.created_at).getTime()))
|
||||
)
|
||||
: null;
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
label: "TOTAL",
|
||||
...statusMeta[0],
|
||||
count: data?.totalAds || 0,
|
||||
color: "bg-primary text-primary-foreground",
|
||||
},
|
||||
{
|
||||
label: "PUBLISHED",
|
||||
...statusMeta[1],
|
||||
count: data?.statusCounts.PUBLISHED || 0,
|
||||
color: "bg-green-100 text-green-800",
|
||||
},
|
||||
{
|
||||
label: "DRAFT",
|
||||
...statusMeta[2],
|
||||
count: data?.statusCounts.DRAFT || 0,
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
},
|
||||
{
|
||||
label: "IN REVIEW",
|
||||
...statusMeta[3],
|
||||
count: inReviewCount,
|
||||
},
|
||||
{
|
||||
...statusMeta[4],
|
||||
count: data?.statusCounts.PAUSED || 0,
|
||||
},
|
||||
{
|
||||
...statusMeta[5],
|
||||
count: data?.statusCounts.REJECTED || 0,
|
||||
},
|
||||
];
|
||||
|
||||
// Extra info tiles
|
||||
const extraTiles = [
|
||||
{
|
||||
label: "Poster Ads",
|
||||
icon: Image,
|
||||
count: data?.posterAds || 0,
|
||||
color: "bg-purple-100 text-purple-800",
|
||||
},
|
||||
{
|
||||
label: "Line Ads",
|
||||
icon: List,
|
||||
count: data?.lineAds,
|
||||
color: "bg-pink-100 text-pink-800",
|
||||
},
|
||||
{
|
||||
label: "Video Ads",
|
||||
icon: Video,
|
||||
count: data?.videoAds,
|
||||
color: "bg-blue-100 text-blue-800",
|
||||
},
|
||||
{
|
||||
label: "PAUSED",
|
||||
count: data?.statusCounts.PAUSED || 0,
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
},
|
||||
{
|
||||
label: "REJECTED",
|
||||
count: data?.statusCounts.REJECTED || 0,
|
||||
color: "bg-red-100 text-red-800",
|
||||
label: "Last Ad Posted",
|
||||
icon: Clock,
|
||||
count: lastAdDate
|
||||
? lastAdDate.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "-",
|
||||
color: "bg-gray-50 text-gray-700",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
{tiles.map((tile) => (
|
||||
<Card key={tile.label} className={tile.color}>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm font-medium">{tile.label}</p>
|
||||
<p className="text-3xl font-bold mt-1">{tile.count}</p>
|
||||
<Card
|
||||
key={tile.label}
|
||||
className={`shadow-md border ${tile.color} flex flex-col items-start justify-between`}
|
||||
>
|
||||
<CardContent className="px-5 pb-1 flex flex-col items-start">
|
||||
<div className="rounded-full p-2 mb-3 bg-white/60 flex items-center justify-center">
|
||||
<tile.icon className="h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide mb-1">
|
||||
{tile.label}
|
||||
</p>
|
||||
<p className="text-3xl font-bold">{tile.count}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{extraTiles.map((tile) => (
|
||||
<Card
|
||||
key={tile.label}
|
||||
className={`shadow-md border ${tile.color} flex flex-col items-start justify-between`}
|
||||
>
|
||||
<CardContent
|
||||
className="px-5 pb-1
|
||||
flex flex-col items-start"
|
||||
>
|
||||
<div className="rounded-full p-2 mb-3 bg-white/60 flex items-center justify-center">
|
||||
<tile.icon className="h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide mb-1">
|
||||
{tile.label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{tile.count}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
91
src/components/forms/ad-fields.tsx
Normal file
91
src/components/forms/ad-fields.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { PageType } from "@/lib/enum/page-type";
|
||||
import { PositionType } from "@/lib/enum/position-type";
|
||||
import { z } from "zod";
|
||||
|
||||
const baseFields = {
|
||||
mainCategoryId: z.string().min(1, { message: "Main category is required" }),
|
||||
categoryOneId: z.string().optional(),
|
||||
categoryTwoId: z.string().optional(),
|
||||
categoryThreeId: z.string().optional(),
|
||||
state: z.string().min(1, { message: "State is required" }),
|
||||
sid: z.number().optional(),
|
||||
city: z.string().min(1, { message: "City is required" }),
|
||||
cid: z.number().optional(),
|
||||
postedBy: z.string().min(1, { message: "Posted by is required" }),
|
||||
dates: z.array(z.date()).min(1, "At least one date is required"),
|
||||
pageType: z.nativeEnum(PageType),
|
||||
contactOne: z.coerce
|
||||
.number()
|
||||
.refine((n) => String(n).replace(/\D/g, "").length >= 10, {
|
||||
message: "Phone number must be at least 10 digits",
|
||||
}),
|
||||
contactTwo: z.coerce.number().optional(),
|
||||
};
|
||||
|
||||
const lineAd = z.object({
|
||||
adType: z.literal("LINE"),
|
||||
...baseFields,
|
||||
content: z.string().min(1, { message: "Ad content is required" }),
|
||||
imageIds: z.array(z.string()).max(3, { message: "Maximum 3 images allowed" }),
|
||||
});
|
||||
|
||||
const posterAd = z.object({
|
||||
adType: z.literal("POSTER"),
|
||||
...baseFields,
|
||||
imageId: z.string().min(1, { message: "Image is required" }),
|
||||
side: z.nativeEnum(PositionType),
|
||||
position: z.number().int().min(1).max(6).optional(), // required only for LEFT/RIGHT
|
||||
});
|
||||
|
||||
const videoAd = z.object({
|
||||
adType: z.literal("VIDEO"),
|
||||
...baseFields,
|
||||
imageId: z.string().min(1, { message: "Video is required" }),
|
||||
side: z.nativeEnum(PositionType), // CENTER_* disallowed via union-level refine
|
||||
position: z.number().int().min(1).max(6), // always required
|
||||
});
|
||||
|
||||
// Discriminated union
|
||||
const adFormUnion = z.discriminatedUnion("adType", [lineAd, posterAd, videoAd]);
|
||||
|
||||
// Put all cross-field logic here so options stay ZodObject
|
||||
export const adFormSchema = adFormUnion.superRefine((data, ctx) => {
|
||||
if (data.adType === "POSTER") {
|
||||
const isSide =
|
||||
data.side === PositionType.LEFT_SIDE ||
|
||||
data.side === PositionType.RIGHT_SIDE;
|
||||
const isCenter =
|
||||
data.side === PositionType.CENTER_TOP ||
|
||||
data.side === PositionType.CENTER_BOTTOM;
|
||||
|
||||
if (isSide && data.position === undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["position"],
|
||||
message: "Position number is required for side positions",
|
||||
});
|
||||
}
|
||||
if (isCenter && data.position !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["position"],
|
||||
message: "Position number is not allowed for center positions",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.adType === "VIDEO") {
|
||||
const isCenter =
|
||||
data.side === PositionType.CENTER_TOP ||
|
||||
data.side === PositionType.CENTER_BOTTOM;
|
||||
if (isCenter) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["side"],
|
||||
message: "Video ads cannot use CENTER_TOP or CENTER_BOTTOM positions",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type AdFormValues = z.infer<typeof adFormSchema>;
|
||||
164
src/components/forms/ad-position-selector.tsx
Normal file
164
src/components/forms/ad-position-selector.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Control, FieldPath } from "react-hook-form";
|
||||
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { PageType } from "@/lib/enum/page-type";
|
||||
import { PositionType } from "@/lib/enum/position-type";
|
||||
import { AdType } from "@/lib/enum/ad-type";
|
||||
|
||||
interface AdPositionSelectorProps<T extends Record<string, any>> {
|
||||
control: Control<T>;
|
||||
adType: AdType;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AdPositionSelector<T extends Record<string, any>>({
|
||||
control,
|
||||
adType,
|
||||
disabled = false
|
||||
}: AdPositionSelectorProps<T>) {
|
||||
|
||||
const getPositionTypeOptions = () => {
|
||||
switch (adType) {
|
||||
case AdType.LINE:
|
||||
return []; // Line ads don't have position types
|
||||
case AdType.POSTER:
|
||||
return [
|
||||
{ value: PositionType.LEFT_SIDE, label: "Left Side" },
|
||||
{ value: PositionType.RIGHT_SIDE, label: "Right Side" },
|
||||
{ value: PositionType.CENTER_TOP, label: "Center Top" },
|
||||
{ value: PositionType.CENTER_BOTTOM, label: "Center Bottom" },
|
||||
];
|
||||
case AdType.VIDEO:
|
||||
return [
|
||||
{ value: PositionType.LEFT_SIDE, label: "Left Side" },
|
||||
{ value: PositionType.RIGHT_SIDE, label: "Right Side" },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const requiresPositionNumber = (positionType?: PositionType) => {
|
||||
if (adType === AdType.LINE) return false;
|
||||
if (adType === AdType.VIDEO) return true; // Video ads always require position number
|
||||
if (adType === AdType.POSTER) {
|
||||
return positionType === PositionType.LEFT_SIDE || positionType === PositionType.RIGHT_SIDE;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getPositionNumbers = () => {
|
||||
return Array.from({ length: 6 }, (_, i) => i + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<h3 className="text-lg font-medium">Ad Position</h3>
|
||||
|
||||
{/* Page Type Selection */}
|
||||
<FormField
|
||||
control={control}
|
||||
name={"pageType" as FieldPath<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">Page Type *</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full py-2">
|
||||
<SelectValue placeholder="Select page type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={PageType.HOME}>Home Page</SelectItem>
|
||||
<SelectItem value={PageType.CATEGORY}>Category Page</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Position Type Selection - Only for Poster and Video ads */}
|
||||
{adType !== AdType.LINE && (
|
||||
<FormField
|
||||
control={control}
|
||||
name={"side" as FieldPath<T>}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">Position Type *</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full py-2">
|
||||
<SelectValue placeholder="Select position type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{getPositionTypeOptions().map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Position Number Selection - Conditional based on ad type and position type */}
|
||||
{adType !== AdType.LINE && (
|
||||
<FormField
|
||||
control={control}
|
||||
name={"position" as FieldPath<T>}
|
||||
render={({ field }) => {
|
||||
// Watch the positionType field to determine if position number should be shown
|
||||
const formValues = control._formValues;
|
||||
const positionType = formValues?.side;
|
||||
const shouldShowPositionNumber = requiresPositionNumber(positionType);
|
||||
|
||||
if (!shouldShowPositionNumber) {
|
||||
return <div className="hidden" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium">Position Number *</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
value={field.value?.toString()}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full py-2">
|
||||
<SelectValue placeholder="Select position number" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{getPositionNumbers().map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
Position {num}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
285
src/components/forms/otp-viewer-login.tsx
Normal file
285
src/components/forms/otp-viewer-login.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
sendLoginOtpSchema,
|
||||
verifyLoginOtpSchema,
|
||||
type SendLoginOtpValues,
|
||||
type VerifyLoginOtpValues
|
||||
} from "@/lib/validations";
|
||||
import api from "@/lib/api";
|
||||
import { Phone, Shield, ArrowLeft } from "lucide-react";
|
||||
|
||||
interface OtpViewerLoginProps {
|
||||
onSuccess: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export default function OtpViewerLogin({
|
||||
onSuccess,
|
||||
onBack
|
||||
}: OtpViewerLoginProps) {
|
||||
const [step, setStep] = useState<"phone" | "otp">("phone");
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [otpValue, setOtpValue] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Phone form
|
||||
const phoneForm = useForm<SendLoginOtpValues>({
|
||||
resolver: zodResolver(sendLoginOtpSchema),
|
||||
defaultValues: {
|
||||
phone: "",
|
||||
},
|
||||
});
|
||||
|
||||
// OTP form
|
||||
const otpForm = useForm<VerifyLoginOtpValues>({
|
||||
resolver: zodResolver(verifyLoginOtpSchema),
|
||||
defaultValues: {
|
||||
phone: "",
|
||||
otp: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Send login OTP mutation
|
||||
const sendOtpMutation = useMutation({
|
||||
mutationFn: async (data: SendLoginOtpValues) => {
|
||||
const response = await api.post("/auth/send-otp", data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("OTP sent to your phone");
|
||||
setOtpValue("");
|
||||
otpForm.reset({
|
||||
phone: phoneNumber,
|
||||
otp: "",
|
||||
});
|
||||
setStep("otp");
|
||||
setCountdown(300); // 5 minutes countdown
|
||||
startCountdown();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Send OTP error:", error);
|
||||
const errorMessage = error.response?.data?.message || "Failed to send OTP. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
// Verify login OTP mutation
|
||||
const verifyOtpMutation = useMutation({
|
||||
mutationFn: async (data: VerifyLoginOtpValues) => {
|
||||
const response = await api.post("/auth/verify-otp", data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success("Login successful!");
|
||||
// Invalidate user query to refetch user data
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Verify OTP error:", error);
|
||||
const errorMessage = error.response?.data?.message || "Invalid or expired OTP. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
// Reset OTP field
|
||||
setOtpValue("");
|
||||
otpForm.setValue("otp", "");
|
||||
},
|
||||
});
|
||||
|
||||
// Countdown timer
|
||||
const startCountdown = () => {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
function onSendOtp(data: SendLoginOtpValues) {
|
||||
setPhoneNumber(data.phone);
|
||||
sendOtpMutation.mutate(data);
|
||||
}
|
||||
|
||||
function onVerifyOtp(data: VerifyLoginOtpValues) {
|
||||
// Use our controlled OTP value
|
||||
const submitData = {
|
||||
phone: phoneNumber,
|
||||
otp: otpValue,
|
||||
};
|
||||
verifyOtpMutation.mutate(submitData);
|
||||
}
|
||||
|
||||
const handleResendOtp = () => {
|
||||
// Clear OTP field before resending
|
||||
setOtpValue("");
|
||||
otpForm.setValue("otp", "");
|
||||
sendOtpMutation.mutate({ phone: phoneNumber });
|
||||
};
|
||||
|
||||
if (step === "phone") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<Phone className="mx-auto h-12 w-12 text-primary mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Quick Login with Phone</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Enter your phone number to receive an OTP and access advertisements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...phoneForm}>
|
||||
<form onSubmit={phoneForm.handleSubmit(onSendOtp)} className="space-y-4">
|
||||
<FormField
|
||||
control={phoneForm.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone Number</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="1234567890"
|
||||
className="pl-10"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
// Only allow numeric input
|
||||
const value = e.target.value.replace(/\D/g, "");
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={sendOtpMutation.isPending}
|
||||
>
|
||||
{sendOtpMutation.isPending ? "Sending OTP..." : "Send OTP"}
|
||||
</Button>
|
||||
|
||||
{onBack && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<Shield className="mx-auto h-12 w-12 text-primary mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Enter OTP</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
We've sent a 6-digit code to +91 {phoneNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...otpForm}>
|
||||
<form onSubmit={otpForm.handleSubmit(onVerifyOtp)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Verification Code</label>
|
||||
<Input
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
className="text-center text-xl tracking-widest"
|
||||
value={otpValue}
|
||||
onChange={(e) => {
|
||||
// Only allow numeric input
|
||||
const value = e.target.value.replace(/\D/g, "");
|
||||
if (value.length <= 6) {
|
||||
setOtpValue(value);
|
||||
// Also update the form field for validation
|
||||
otpForm.setValue("otp", value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={verifyOtpMutation.isPending || otpValue.length !== 6}
|
||||
>
|
||||
{verifyOtpMutation.isPending ? "Verifying..." : "Verify & Login"}
|
||||
</Button>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
{countdown > 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Resend OTP in {formatTime(countdown)}
|
||||
</p>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleResendOtp}
|
||||
disabled={sendOtpMutation.isPending}
|
||||
className="text-primary hover:text-primary/80"
|
||||
>
|
||||
{sendOtpMutation.isPending ? "Sending..." : "Resend OTP"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
// Clear OTP field when going back
|
||||
setOtpValue("");
|
||||
otpForm.setValue("otp", "");
|
||||
setStep("phone");
|
||||
}}
|
||||
disabled={verifyOtpMutation.isPending}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Phone Entry
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user