Compare commits

...

10 Commits

Author SHA1 Message Date
46f24c10fe update 2025-08-18 20:00:42 +05:30
a26ca40a5b fix 2025-08-18 15:23:44 +05:30
5f373cf006 fix 2025-08-14 19:52:11 +05:30
75fd1e407a update 2025-07-25 00:20:55 +05:30
68a100c1e9 update 2025-07-24 17:33:39 +05:30
845234ace8 feat: update 2025-07-23 20:13:28 +05:30
1b5a1f31ba update 2025-06-15 02:55:32 +05:30
5cc674de8d update 2025-06-14 16:22:47 +05:30
fd846acb6e update 2025-06-03 14:54:57 +05:30
c69e5a8c6a update 2025-06-03 03:27:51 +05:30
145 changed files with 21495 additions and 6675 deletions

View 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
View File

@@ -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
View File

@@ -0,0 +1,14 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp"
],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
}
}
}
}

135
CLAUDE.md Normal file
View 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
View 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
View 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
View 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

View File

@@ -5,6 +5,7 @@ const nextConfig: NextConfig = {
images: {
unoptimized: true,
},
output:'standalone'
};
export default nextConfig;

304
package-lock.json generated
View File

@@ -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
}
}
}
}
}

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

View 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>
);
}

View File

@@ -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}

View File

@@ -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>
)}
</>

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }) => {

View File

@@ -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>

View File

@@ -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>;
},
},
{

View File

@@ -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>
);
}

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -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);
}
}
}

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -71,8 +71,8 @@ export default function AdsOnHoldPosterAdsPage() {
<DataTable
columns={columns}
data={ads}
searchColumn="title"
searchPlaceholder="Search title..."
searchColumn="dates"
searchPlaceholder="Search dates..."
/>
)}
</div>

View File

@@ -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>
);

View File

@@ -71,8 +71,8 @@ export default function AdsOnHoldVideoAdsPage() {
<DataTable
columns={columns}
data={ads}
searchColumn="title"
searchPlaceholder="Search title..."
searchColumn="dates"
searchPlaceholder="Search dates..."
/>
)}
</div>

View File

@@ -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">

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
&nbsp;·&nbsp;
{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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View File

@@ -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
&nbsp;·&nbsp;
{editor.storage.characterCount.words()} words
</>
)}
</div>
<div>
{lastSaved && (
<span>Last saved: {lastSaved.toLocaleTimeString()}</span>
)}
</div>
</CardFooter>
</Card>
</div>
);
}
export default function TermsConditionsConfig() {
return <TermsAndConditionsPage />;
}

View 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(/&nbsp;/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
&nbsp;·&nbsp;
{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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>;
},
},
{

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View 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>
);
}

View File

@@ -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"

View 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
View 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>
);
}

View File

@@ -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>

View File

@@ -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>
</>
);
}

View File

@@ -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>
))}

View 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>;

View 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

View 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