update
This commit is contained in:
452
SUPPORT_CENTER_SETUP.md
Normal file
452
SUPPORT_CENTER_SETUP.md
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
# Support Center Implementation - Complete Setup Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Enterprise Support Center has been successfully implemented! This comprehensive system allows users to:
|
||||||
|
|
||||||
|
1. **Submit Support Tickets** - Create and track support requests
|
||||||
|
2. **Browse Knowledge Base** - Search and read helpful articles
|
||||||
|
3. **Check Ticket Status** - Monitor ticket progress using ticket numbers
|
||||||
|
4. **No Authentication Required** - Public access to support features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ What's Been Implemented
|
||||||
|
|
||||||
|
### Backend (Django)
|
||||||
|
|
||||||
|
#### 1. **Models** (`backend/support/models.py`)
|
||||||
|
- `TicketStatus` - Track ticket lifecycle (Open, In Progress, Resolved, etc.)
|
||||||
|
- `TicketPriority` - Priority levels (Low, Medium, High, Critical) with SLA hours
|
||||||
|
- `TicketCategory` - Organize tickets by category
|
||||||
|
- `SupportTicket` - Main ticket model with auto-generated ticket numbers
|
||||||
|
- `TicketMessage` - Conversation history on tickets
|
||||||
|
- `TicketActivity` - Audit trail of all changes
|
||||||
|
- `KnowledgeBaseCategory` - KB article categories
|
||||||
|
- `KnowledgeBaseArticle` - Help articles with view counts and feedback
|
||||||
|
- `SupportSettings` - Configurable support center settings
|
||||||
|
|
||||||
|
#### 2. **API Endpoints** (`backend/support/views.py`)
|
||||||
|
|
||||||
|
**Tickets:**
|
||||||
|
- `POST /api/support/tickets/` - Create new ticket
|
||||||
|
- `GET /api/support/tickets/` - List tickets
|
||||||
|
- `POST /api/support/tickets/check-status/` - Check status by ticket number
|
||||||
|
- `POST /api/support/tickets/{id}/add-message/` - Add message to ticket
|
||||||
|
|
||||||
|
**Categories & Settings:**
|
||||||
|
- `GET /api/support/categories/` - List ticket categories
|
||||||
|
- `GET /api/support/statuses/` - List ticket statuses
|
||||||
|
- `GET /api/support/priorities/` - List priorities
|
||||||
|
|
||||||
|
**Knowledge Base:**
|
||||||
|
- `GET /api/support/knowledge-base/` - List articles (with search)
|
||||||
|
- `GET /api/support/knowledge-base/{slug}/` - Get article details
|
||||||
|
- `GET /api/support/knowledge-base/featured/` - Featured articles
|
||||||
|
- `POST /api/support/knowledge-base/{slug}/mark-helpful/` - Rate article
|
||||||
|
|
||||||
|
#### 3. **Admin Panel** (`backend/support/admin.py`)
|
||||||
|
Full Django admin integration for managing:
|
||||||
|
- Tickets with inline messages and activities
|
||||||
|
- Categories, statuses, and priorities
|
||||||
|
- Knowledge base articles and categories
|
||||||
|
- Support settings
|
||||||
|
|
||||||
|
#### 4. **Management Command**
|
||||||
|
```bash
|
||||||
|
python manage.py populate_support_data
|
||||||
|
```
|
||||||
|
Populates initial data:
|
||||||
|
- 5 ticket statuses
|
||||||
|
- 4 priority levels
|
||||||
|
- 6 ticket categories
|
||||||
|
- 6 KB categories
|
||||||
|
- 6 sample KB articles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend (Next.js/React)
|
||||||
|
|
||||||
|
#### 1. **Page Route**
|
||||||
|
- `/support-center` - Main support center page
|
||||||
|
|
||||||
|
#### 2. **Components** (`components/pages/support/`)
|
||||||
|
|
||||||
|
**Main Components:**
|
||||||
|
- `SupportCenterHero.tsx` - Hero section with feature highlights
|
||||||
|
- `SupportCenterContent.tsx` - Tabbed interface container
|
||||||
|
- `CreateTicketForm.tsx` - Ticket submission form
|
||||||
|
- `TicketStatusCheck.tsx` - Check ticket progress
|
||||||
|
- `KnowledgeBase.tsx` - Browse and search articles
|
||||||
|
- `KnowledgeBaseArticleModal.tsx` - Article detail modal
|
||||||
|
|
||||||
|
#### 3. **API Services** (`lib/api/supportService.ts`)
|
||||||
|
TypeScript API client with functions for:
|
||||||
|
- Creating tickets
|
||||||
|
- Checking status
|
||||||
|
- Managing KB articles
|
||||||
|
- All CRUD operations
|
||||||
|
|
||||||
|
#### 4. **Custom Hooks** (`lib/hooks/useSupport.ts`)
|
||||||
|
React hooks for data fetching:
|
||||||
|
- `useTicketCategories()`
|
||||||
|
- `useKnowledgeBaseCategories()`
|
||||||
|
- `useFeaturedArticles()`
|
||||||
|
- `useKnowledgeBaseArticle(slug)`
|
||||||
|
- And more...
|
||||||
|
|
||||||
|
#### 5. **Styling** (`public/styles/pages/_support-center.scss`)
|
||||||
|
Complete, modern styling including:
|
||||||
|
- Gradient hero section
|
||||||
|
- Tabbed interface
|
||||||
|
- Responsive forms
|
||||||
|
- Article cards and modals
|
||||||
|
- Loading and error states
|
||||||
|
- Mobile-responsive design
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
|
||||||
|
1. **Navigate to backend:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Database is already migrated and populated!** ✅
|
||||||
|
- Migrations applied
|
||||||
|
- Initial data loaded
|
||||||
|
|
||||||
|
3. **Start Django server:**
|
||||||
|
```bash
|
||||||
|
../venv/bin/python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access Admin Panel:**
|
||||||
|
- URL: http://localhost:8000/admin
|
||||||
|
- Create a superuser if needed:
|
||||||
|
```bash
|
||||||
|
../venv/bin/python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Setup
|
||||||
|
|
||||||
|
1. **Navigate to project root:**
|
||||||
|
```bash
|
||||||
|
cd /home/gnx/Desktop/GNX-WEB/gnx-react
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies (if not already):**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start development server:**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Access Support Center:**
|
||||||
|
- URL: http://localhost:3000/support-center
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Key Features
|
||||||
|
|
||||||
|
### 1. Submit Tickets
|
||||||
|
- **Form Fields:**
|
||||||
|
- Personal info (name, email, phone, company)
|
||||||
|
- Issue type (technical, billing, feature request, etc.)
|
||||||
|
- Category selection
|
||||||
|
- Subject and detailed description
|
||||||
|
|
||||||
|
- **Auto-Generated Ticket Numbers:**
|
||||||
|
- Format: `TKT-YYYYMMDD-XXXXX`
|
||||||
|
- Example: `TKT-20251007-A3B9C`
|
||||||
|
|
||||||
|
- **Success Confirmation:**
|
||||||
|
- Displays ticket number
|
||||||
|
- Guidance to save for tracking
|
||||||
|
|
||||||
|
### 2. Knowledge Base
|
||||||
|
- **Search Functionality:**
|
||||||
|
- Real-time search across articles
|
||||||
|
- Search by title, content, keywords
|
||||||
|
|
||||||
|
- **Category Browser:**
|
||||||
|
- 6 pre-populated categories
|
||||||
|
- Color-coded icons
|
||||||
|
- Article count per category
|
||||||
|
|
||||||
|
- **Featured Articles:**
|
||||||
|
- Highlighted important articles
|
||||||
|
- View counts
|
||||||
|
- Helpful/not helpful ratings
|
||||||
|
|
||||||
|
- **Article Modal:**
|
||||||
|
- Full article content
|
||||||
|
- Rich text support
|
||||||
|
- Feedback buttons
|
||||||
|
- Article metadata
|
||||||
|
|
||||||
|
### 3. Ticket Status Checker
|
||||||
|
- **Check by Ticket Number:**
|
||||||
|
- Enter ticket number
|
||||||
|
- View complete ticket details
|
||||||
|
|
||||||
|
- **Displays:**
|
||||||
|
- Current status with color coding
|
||||||
|
- Priority level
|
||||||
|
- Creation and update dates
|
||||||
|
- Full description
|
||||||
|
- Message history
|
||||||
|
- Activity timeline
|
||||||
|
|
||||||
|
### 4. Navigation Update
|
||||||
|
- **Navbar Button Changed:**
|
||||||
|
- Old: "Get Started" → `/contact-us`
|
||||||
|
- New: "Support Center" → `/support-center`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Database Schema
|
||||||
|
|
||||||
|
### Pre-Populated Data
|
||||||
|
|
||||||
|
**Ticket Statuses:**
|
||||||
|
- Open (Blue)
|
||||||
|
- In Progress (Orange)
|
||||||
|
- Pending Response (Purple)
|
||||||
|
- Resolved (Green)
|
||||||
|
- Closed (Gray)
|
||||||
|
|
||||||
|
**Priorities:**
|
||||||
|
- Critical (4 hours SLA)
|
||||||
|
- High (24 hours SLA)
|
||||||
|
- Medium (48 hours SLA)
|
||||||
|
- Low (72 hours SLA)
|
||||||
|
|
||||||
|
**Ticket Categories:**
|
||||||
|
- Technical Support
|
||||||
|
- Billing & Payments
|
||||||
|
- Account Management
|
||||||
|
- Product Inquiry
|
||||||
|
- Bug Reports
|
||||||
|
- Feature Requests
|
||||||
|
|
||||||
|
**Knowledge Base Categories:**
|
||||||
|
- Getting Started
|
||||||
|
- Account & Billing
|
||||||
|
- Technical Documentation
|
||||||
|
- Troubleshooting
|
||||||
|
- Security & Privacy
|
||||||
|
- Best Practices
|
||||||
|
|
||||||
|
**Sample Articles:**
|
||||||
|
- How to Get Started with Our Platform
|
||||||
|
- Understanding Your Billing Cycle
|
||||||
|
- API Documentation Overview
|
||||||
|
- Common Login Issues and Solutions
|
||||||
|
- How to Update Your Payment Method
|
||||||
|
- Security Best Practices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### API Base URL
|
||||||
|
Located in: `lib/config/api.ts`
|
||||||
|
- Default: `http://localhost:8000/api`
|
||||||
|
|
||||||
|
### Django Settings
|
||||||
|
Support app added to `INSTALLED_APPS` in `backend/gnx/settings.py`
|
||||||
|
|
||||||
|
### URLs
|
||||||
|
Support endpoints registered in `backend/gnx/urls.py`:
|
||||||
|
```python
|
||||||
|
path('support/', include('support.urls')),
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI/UX Features
|
||||||
|
|
||||||
|
### Design Elements
|
||||||
|
- **Modern Gradient Hero** - Dark theme with gold accents
|
||||||
|
- **Tabbed Interface** - Easy navigation between features
|
||||||
|
- **Responsive Design** - Mobile-first approach
|
||||||
|
- **Loading States** - Spinners for async operations
|
||||||
|
- **Error Handling** - User-friendly error messages
|
||||||
|
- **Success Feedback** - Confirmation messages
|
||||||
|
- **Color-Coded Status** - Visual ticket status indicators
|
||||||
|
|
||||||
|
### Animations
|
||||||
|
- GSAP scroll animations on hero
|
||||||
|
- Smooth tab transitions
|
||||||
|
- Modal fade-in/slide-up effects
|
||||||
|
- Hover effects on cards and buttons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Usage Examples
|
||||||
|
|
||||||
|
### Create a Ticket (API)
|
||||||
|
```javascript
|
||||||
|
import { createTicket } from '@/lib/api/supportService';
|
||||||
|
|
||||||
|
const ticketData = {
|
||||||
|
title: "Login issues on mobile app",
|
||||||
|
description: "Cannot login using my credentials...",
|
||||||
|
ticket_type: "technical",
|
||||||
|
user_name: "John Doe",
|
||||||
|
user_email: "john@example.com",
|
||||||
|
user_phone: "+1234567890",
|
||||||
|
company: "Acme Corp",
|
||||||
|
category: 1 // Technical Support
|
||||||
|
};
|
||||||
|
|
||||||
|
const ticket = await createTicket(ticketData);
|
||||||
|
console.log(ticket.ticket_number); // TKT-20251007-A3B9C
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Ticket Status (API)
|
||||||
|
```javascript
|
||||||
|
import { checkTicketStatus } from '@/lib/api/supportService';
|
||||||
|
|
||||||
|
const ticket = await checkTicketStatus('TKT-20251007-A3B9C');
|
||||||
|
console.log(ticket.status_name); // "Open"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Knowledge Base (API)
|
||||||
|
```javascript
|
||||||
|
import { getKnowledgeBaseArticles } from '@/lib/api/supportService';
|
||||||
|
|
||||||
|
const articles = await getKnowledgeBaseArticles('login');
|
||||||
|
articles.forEach(article => {
|
||||||
|
console.log(article.title);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security Notes
|
||||||
|
|
||||||
|
- **Public Access:** All endpoints are public (no authentication required)
|
||||||
|
- **Ticket Numbers:** Randomly generated to prevent guessing
|
||||||
|
- **Internal Notes:** Hidden from public API responses
|
||||||
|
- **Rate Limiting:** Recommended for production deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Future Enhancements
|
||||||
|
|
||||||
|
Potential additions for future development:
|
||||||
|
|
||||||
|
- [ ] **Live Chat Integration** - Real-time customer support
|
||||||
|
- [ ] **File Attachments** - Upload files with tickets
|
||||||
|
- [ ] **Email Notifications** - Automated ticket updates
|
||||||
|
- [ ] **Ticket Assignment** - Auto-routing to support agents
|
||||||
|
- [ ] **SLA Alerts** - Breach notifications
|
||||||
|
- [ ] **Analytics Dashboard** - Support metrics and reports
|
||||||
|
- [ ] **Multi-language Support** - Internationalization
|
||||||
|
- [ ] **Webhook Notifications** - Integration with external systems
|
||||||
|
- [ ] **Customer Portal** - User authentication for ticket management
|
||||||
|
- [ ] **Advanced Search** - Filters, sorting, faceted search
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Testing the Implementation
|
||||||
|
|
||||||
|
### Test Ticket Creation:
|
||||||
|
1. Go to http://localhost:3000/support-center
|
||||||
|
2. Click "Submit a Ticket" tab
|
||||||
|
3. Fill out the form
|
||||||
|
4. Submit and note the ticket number
|
||||||
|
|
||||||
|
### Test Status Check:
|
||||||
|
1. Click "Check Ticket Status" tab
|
||||||
|
2. Enter the ticket number from above
|
||||||
|
3. View complete ticket details
|
||||||
|
|
||||||
|
### Test Knowledge Base:
|
||||||
|
1. Click "Knowledge Base" tab
|
||||||
|
2. Browse categories or use search
|
||||||
|
3. Click an article to view details
|
||||||
|
4. Rate article as helpful/not helpful
|
||||||
|
|
||||||
|
### Test Admin Panel:
|
||||||
|
1. Go to http://localhost:8000/admin
|
||||||
|
2. Navigate to Support section
|
||||||
|
3. View/edit tickets, categories, articles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gnx-react/
|
||||||
|
├── app/
|
||||||
|
│ └── support-center/
|
||||||
|
│ └── page.tsx # Main support page
|
||||||
|
├── components/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── support/
|
||||||
|
│ ├── SupportCenterHero.tsx
|
||||||
|
│ ├── SupportCenterContent.tsx
|
||||||
|
│ ├── CreateTicketForm.tsx
|
||||||
|
│ ├── TicketStatusCheck.tsx
|
||||||
|
│ ├── KnowledgeBase.tsx
|
||||||
|
│ └── KnowledgeBaseArticleModal.tsx
|
||||||
|
├── lib/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── supportService.ts # API client
|
||||||
|
│ └── hooks/
|
||||||
|
│ └── useSupport.ts # React hooks
|
||||||
|
├── public/
|
||||||
|
│ └── styles/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── _support-center.scss # Styles
|
||||||
|
└── backend/
|
||||||
|
└── support/
|
||||||
|
├── models.py # Database models
|
||||||
|
├── serializers.py # API serializers
|
||||||
|
├── views.py # API views
|
||||||
|
├── urls.py # URL routing
|
||||||
|
├── admin.py # Admin config
|
||||||
|
├── management/
|
||||||
|
│ └── commands/
|
||||||
|
│ └── populate_support_data.py
|
||||||
|
└── README.md # Module docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Summary
|
||||||
|
|
||||||
|
The Enterprise Support Center is now fully operational with:
|
||||||
|
|
||||||
|
✅ **Backend:** Complete Django REST API with 8 models and full CRUD operations
|
||||||
|
✅ **Frontend:** Modern React/Next.js interface with 6 components
|
||||||
|
✅ **Database:** Migrated and populated with sample data
|
||||||
|
✅ **Styling:** Beautiful, responsive SCSS styles
|
||||||
|
✅ **Navigation:** Updated navbar button
|
||||||
|
✅ **Documentation:** Comprehensive README and guides
|
||||||
|
|
||||||
|
**You're ready to provide enterprise-level support to your customers!** 🎉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Need Help?
|
||||||
|
|
||||||
|
If you encounter any issues:
|
||||||
|
|
||||||
|
1. Check that both Django and Next.js servers are running
|
||||||
|
2. Verify the API base URL in `lib/config/api.ts`
|
||||||
|
3. Check browser console for errors
|
||||||
|
4. Review Django logs for backend issues
|
||||||
|
5. Ensure all migrations are applied
|
||||||
|
|
||||||
|
Enjoy your new Support Center! 🚀
|
||||||
|
|
||||||
@@ -1,15 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import Header from "@/components/shared/layout/header/Header";
|
import Header from "@/components/shared/layout/header/Header";
|
||||||
import JobSingle from "@/components/pages/career/JobSingle";
|
import JobSingle from "@/components/pages/career/JobSingle";
|
||||||
import Footer from "@/components/shared/layout/footer/Footer";
|
import Footer from "@/components/shared/layout/footer/Footer";
|
||||||
import CareerScrollProgressButton from "@/components/pages/career/CareerScrollProgressButton";
|
import CareerScrollProgressButton from "@/components/pages/career/CareerScrollProgressButton";
|
||||||
import CareerInitAnimations from "@/components/pages/career/CareerInitAnimations";
|
import CareerInitAnimations from "@/components/pages/career/CareerInitAnimations";
|
||||||
|
import { useJob } from "@/lib/hooks/useCareer";
|
||||||
|
|
||||||
|
const JobPage = () => {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = params?.slug as string;
|
||||||
|
const { job, loading, error } = useJob(slug);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="tp-app">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<section className="pt-120 pb-120">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12 text-center">
|
||||||
|
<h2>Loading job details...</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
<CareerScrollProgressButton />
|
||||||
|
<CareerInitAnimations />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !job) {
|
||||||
|
return (
|
||||||
|
<div className="tp-app">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<section className="pt-120 pb-120">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12 text-center">
|
||||||
|
<h2 className="text-danger">Job Not Found</h2>
|
||||||
|
<p className="mt-24">
|
||||||
|
The job position you are looking for does not exist or is no longer available.
|
||||||
|
</p>
|
||||||
|
<a href="/career" className="btn mt-40">
|
||||||
|
View All Positions
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
<CareerScrollProgressButton />
|
||||||
|
<CareerInitAnimations />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const page = () => {
|
|
||||||
return (
|
return (
|
||||||
<div className="tp-app">
|
<div className="tp-app">
|
||||||
<Header />
|
<Header />
|
||||||
<main>
|
<main>
|
||||||
<JobSingle />
|
<JobSingle job={job} />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
<CareerScrollProgressButton />
|
<CareerScrollProgressButton />
|
||||||
@@ -18,4 +76,4 @@ const page = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default page;
|
export default JobPage;
|
||||||
|
|||||||
25
gnx-react/app/support-center/page.tsx
Normal file
25
gnx-react/app/support-center/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Header from "@/components/shared/layout/header/Header";
|
||||||
|
import Footer from "@/components/shared/layout/footer/Footer";
|
||||||
|
import SupportCenterHero from "@/components/pages/support/SupportCenterHero";
|
||||||
|
import SupportCenterContent from "@/components/pages/support/SupportCenterContent";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Enterprise Support Center | GNX Software Solutions",
|
||||||
|
description: "Get expert support with our comprehensive Enterprise Support Center. Submit tickets, browse knowledge base, and track your support requests.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SupportCenterPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="tp-app">
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
<SupportCenterHero />
|
||||||
|
<SupportCenterContent />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupportCenterPage;
|
||||||
|
|
||||||
BIN
gnx-react/backend/career/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gnx-react/backend/career/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/career/__pycache__/admin.cpython-312.pyc
Normal file
BIN
gnx-react/backend/career/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/career/__pycache__/apps.cpython-312.pyc
Normal file
BIN
gnx-react/backend/career/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
gnx-react/backend/career/__pycache__/models.cpython-312.pyc
Normal file
BIN
gnx-react/backend/career/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/career/__pycache__/serializers.cpython-312.pyc
Normal file
BIN
gnx-react/backend/career/__pycache__/serializers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/career/__pycache__/urls.cpython-312.pyc
Normal file
BIN
gnx-react/backend/career/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/career/__pycache__/views.cpython-312.pyc
Normal file
BIN
gnx-react/backend/career/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
149
gnx-react/backend/career/admin.py
Normal file
149
gnx-react/backend/career/admin.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from .models import JobPosition, JobApplication
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(JobPosition)
|
||||||
|
class JobPositionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'title',
|
||||||
|
'department',
|
||||||
|
'location',
|
||||||
|
'open_positions',
|
||||||
|
'employment_type',
|
||||||
|
'status_badge',
|
||||||
|
'featured',
|
||||||
|
'posted_date',
|
||||||
|
'applications_count'
|
||||||
|
]
|
||||||
|
list_filter = ['status', 'employment_type', 'location_type', 'featured', 'department', 'posted_date']
|
||||||
|
search_fields = ['title', 'department', 'location', 'short_description']
|
||||||
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
|
readonly_fields = ['posted_date', 'updated_date', 'applications_count']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('title', 'slug', 'department', 'status', 'featured', 'priority')
|
||||||
|
}),
|
||||||
|
('Employment Details', {
|
||||||
|
'fields': ('employment_type', 'location_type', 'location', 'open_positions', 'experience_required')
|
||||||
|
}),
|
||||||
|
('Salary Information', {
|
||||||
|
'fields': ('salary_min', 'salary_max', 'salary_currency', 'salary_period', 'salary_additional')
|
||||||
|
}),
|
||||||
|
('Job Description', {
|
||||||
|
'fields': ('short_description', 'about_role')
|
||||||
|
}),
|
||||||
|
('Requirements & Qualifications', {
|
||||||
|
'fields': ('requirements', 'responsibilities', 'qualifications', 'bonus_points', 'benefits'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Dates', {
|
||||||
|
'fields': ('start_date', 'deadline', 'posted_date', 'updated_date')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def status_badge(self, obj):
|
||||||
|
colors = {
|
||||||
|
'active': 'green',
|
||||||
|
'closed': 'red',
|
||||||
|
'draft': 'orange'
|
||||||
|
}
|
||||||
|
return format_html(
|
||||||
|
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||||||
|
colors.get(obj.status, 'black'),
|
||||||
|
obj.get_status_display()
|
||||||
|
)
|
||||||
|
status_badge.short_description = 'Status'
|
||||||
|
|
||||||
|
def applications_count(self, obj):
|
||||||
|
count = obj.applications.count()
|
||||||
|
return format_html(
|
||||||
|
'<span style="font-weight: bold;">{} application(s)</span>',
|
||||||
|
count
|
||||||
|
)
|
||||||
|
applications_count.short_description = 'Applications'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(JobApplication)
|
||||||
|
class JobApplicationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'full_name',
|
||||||
|
'email',
|
||||||
|
'job',
|
||||||
|
'status_badge',
|
||||||
|
'applied_date',
|
||||||
|
'resume_link'
|
||||||
|
]
|
||||||
|
list_filter = ['status', 'job', 'applied_date']
|
||||||
|
search_fields = ['first_name', 'last_name', 'email', 'job__title']
|
||||||
|
readonly_fields = ['applied_date', 'updated_date', 'resume_link']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Job Information', {
|
||||||
|
'fields': ('job', 'status')
|
||||||
|
}),
|
||||||
|
('Applicant Information', {
|
||||||
|
'fields': ('first_name', 'last_name', 'email', 'phone')
|
||||||
|
}),
|
||||||
|
('Professional Information', {
|
||||||
|
'fields': ('current_position', 'current_company', 'years_of_experience')
|
||||||
|
}),
|
||||||
|
('Application Details', {
|
||||||
|
'fields': ('cover_letter', 'resume', 'resume_link', 'portfolio_url')
|
||||||
|
}),
|
||||||
|
('Social Links', {
|
||||||
|
'fields': ('linkedin_url', 'github_url', 'website_url'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Availability & Salary', {
|
||||||
|
'fields': ('available_from', 'notice_period', 'expected_salary', 'salary_currency'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('applied_date', 'updated_date', 'consent', 'notes')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def status_badge(self, obj):
|
||||||
|
colors = {
|
||||||
|
'new': 'blue',
|
||||||
|
'reviewing': 'orange',
|
||||||
|
'shortlisted': 'purple',
|
||||||
|
'interviewed': 'teal',
|
||||||
|
'accepted': 'green',
|
||||||
|
'rejected': 'red'
|
||||||
|
}
|
||||||
|
return format_html(
|
||||||
|
'<span style="color: {}; font-weight: bold;">{}</span>',
|
||||||
|
colors.get(obj.status, 'black'),
|
||||||
|
obj.get_status_display()
|
||||||
|
)
|
||||||
|
status_badge.short_description = 'Status'
|
||||||
|
|
||||||
|
def resume_link(self, obj):
|
||||||
|
if obj.resume:
|
||||||
|
return format_html(
|
||||||
|
'<a href="{}" target="_blank">Download Resume</a>',
|
||||||
|
obj.resume.url
|
||||||
|
)
|
||||||
|
return '-'
|
||||||
|
resume_link.short_description = 'Resume'
|
||||||
|
|
||||||
|
actions = ['mark_as_reviewing', 'mark_as_shortlisted', 'mark_as_rejected']
|
||||||
|
|
||||||
|
def mark_as_reviewing(self, request, queryset):
|
||||||
|
queryset.update(status='reviewing')
|
||||||
|
self.message_user(request, f'{queryset.count()} application(s) marked as reviewing.')
|
||||||
|
mark_as_reviewing.short_description = 'Mark as Reviewing'
|
||||||
|
|
||||||
|
def mark_as_shortlisted(self, request, queryset):
|
||||||
|
queryset.update(status='shortlisted')
|
||||||
|
self.message_user(request, f'{queryset.count()} application(s) marked as shortlisted.')
|
||||||
|
mark_as_shortlisted.short_description = 'Mark as Shortlisted'
|
||||||
|
|
||||||
|
def mark_as_rejected(self, request, queryset):
|
||||||
|
queryset.update(status='rejected')
|
||||||
|
self.message_user(request, f'{queryset.count()} application(s) marked as rejected.')
|
||||||
|
mark_as_rejected.short_description = 'Mark as Rejected'
|
||||||
|
|
||||||
7
gnx-react/backend/career/apps.py
Normal file
7
gnx-react/backend/career/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CareerConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'career'
|
||||||
|
|
||||||
110
gnx-react/backend/career/email_service.py
Normal file
110
gnx-react/backend/career/email_service.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CareerEmailService:
|
||||||
|
"""Service for handling career-related emails"""
|
||||||
|
|
||||||
|
def send_application_confirmation(self, application):
|
||||||
|
"""
|
||||||
|
Send confirmation email to applicant
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subject = f"Application Received - {application.job.title}"
|
||||||
|
from_email = settings.DEFAULT_FROM_EMAIL
|
||||||
|
to_email = [application.email]
|
||||||
|
|
||||||
|
# Create context for email template
|
||||||
|
context = {
|
||||||
|
'applicant_name': application.full_name,
|
||||||
|
'job_title': application.job.title,
|
||||||
|
'job_location': application.job.location,
|
||||||
|
'application_date': application.applied_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render email templates
|
||||||
|
text_content = render_to_string('career/application_confirmation.txt', context)
|
||||||
|
html_content = render_to_string('career/application_confirmation.html', context)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_content,
|
||||||
|
from_email=from_email,
|
||||||
|
to=to_email
|
||||||
|
)
|
||||||
|
email.attach_alternative(html_content, "text/html")
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
|
||||||
|
logger.info(f"Confirmation email sent to {application.email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send confirmation email: {str(e)}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_application_notification_to_admin(self, application):
|
||||||
|
"""
|
||||||
|
Send notification email to company about new application
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subject = f"New Job Application: {application.job.title} - {application.full_name}"
|
||||||
|
from_email = settings.DEFAULT_FROM_EMAIL
|
||||||
|
to_email = [settings.COMPANY_EMAIL]
|
||||||
|
|
||||||
|
# Create context for email template
|
||||||
|
context = {
|
||||||
|
'applicant_name': application.full_name,
|
||||||
|
'applicant_email': application.email,
|
||||||
|
'applicant_phone': application.phone,
|
||||||
|
'job_title': application.job.title,
|
||||||
|
'current_position': application.current_position,
|
||||||
|
'current_company': application.current_company,
|
||||||
|
'years_of_experience': application.years_of_experience,
|
||||||
|
'cover_letter': application.cover_letter,
|
||||||
|
'portfolio_url': application.portfolio_url,
|
||||||
|
'linkedin_url': application.linkedin_url,
|
||||||
|
'github_url': application.github_url,
|
||||||
|
'website_url': application.website_url,
|
||||||
|
'expected_salary': application.expected_salary,
|
||||||
|
'salary_currency': application.salary_currency,
|
||||||
|
'available_from': application.available_from,
|
||||||
|
'notice_period': application.notice_period,
|
||||||
|
'application_date': application.applied_date,
|
||||||
|
'resume_url': application.resume.url if application.resume else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render email templates
|
||||||
|
text_content = render_to_string('career/application_notification.txt', context)
|
||||||
|
html_content = render_to_string('career/application_notification.html', context)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_content,
|
||||||
|
from_email=from_email,
|
||||||
|
to=to_email,
|
||||||
|
reply_to=[application.email]
|
||||||
|
)
|
||||||
|
email.attach_alternative(html_content, "text/html")
|
||||||
|
|
||||||
|
# Attach resume if available
|
||||||
|
if application.resume:
|
||||||
|
email.attach_file(application.resume.path)
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
|
||||||
|
logger.info(f"Admin notification email sent for application from {application.email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send admin notification email: {str(e)}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
305
gnx-react/backend/career/management/commands/populate_jobs.py
Normal file
305
gnx-react/backend/career/management/commands/populate_jobs.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from career.models import JobPosition
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Populate database with sample job positions'
|
||||||
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
self.stdout.write('Creating sample job positions...')
|
||||||
|
|
||||||
|
jobs_data = [
|
||||||
|
{
|
||||||
|
'title': 'UI/UX Designer',
|
||||||
|
'department': 'Design',
|
||||||
|
'employment_type': 'full-time',
|
||||||
|
'location_type': 'remote',
|
||||||
|
'location': 'Remote',
|
||||||
|
'open_positions': 2,
|
||||||
|
'experience_required': '3+ years',
|
||||||
|
'salary_min': 1500,
|
||||||
|
'salary_max': 2500,
|
||||||
|
'salary_currency': 'USD',
|
||||||
|
'salary_period': 'per month',
|
||||||
|
'salary_additional': '+ VAT (B2B) + bonuses',
|
||||||
|
'short_description': 'Join our design team to create beautiful and intuitive user experiences.',
|
||||||
|
'about_role': 'We are looking for a talented UI/UX Designer who is passionate about creating exceptional user experiences. You will work closely with our product and engineering teams to design and implement user-friendly interfaces for our web and mobile applications.',
|
||||||
|
'requirements': [
|
||||||
|
'At least 3 years of commercial UI/UX design experience',
|
||||||
|
'Strong portfolio showcasing web/UI projects',
|
||||||
|
'Fluent English in verbal and written communication',
|
||||||
|
'Proficiency in design tools (Sketch, Figma, or Adobe XD)',
|
||||||
|
'Understanding of responsive design principles',
|
||||||
|
'Experience with user research and usability testing',
|
||||||
|
],
|
||||||
|
'responsibilities': [
|
||||||
|
'Create wireframes, prototypes, and high-fidelity designs',
|
||||||
|
'Conduct user research and usability testing',
|
||||||
|
'Collaborate with developers to implement designs',
|
||||||
|
'Maintain and evolve design systems',
|
||||||
|
'Present design concepts to stakeholders',
|
||||||
|
],
|
||||||
|
'qualifications': [
|
||||||
|
'Portfolio demonstrating strong UI/UX design skills',
|
||||||
|
'Experience with design systems',
|
||||||
|
'Knowledge of HTML/CSS basics',
|
||||||
|
'Understanding of accessibility standards',
|
||||||
|
],
|
||||||
|
'bonus_points': [
|
||||||
|
'Experience with motion design',
|
||||||
|
'Knowledge of front-end development',
|
||||||
|
'Illustration skills',
|
||||||
|
'Experience with design tokens',
|
||||||
|
],
|
||||||
|
'benefits': [
|
||||||
|
'Remote work flexibility',
|
||||||
|
'Competitive salary package',
|
||||||
|
'Professional development budget',
|
||||||
|
'Latest design tools and software',
|
||||||
|
'Health insurance',
|
||||||
|
'Flexible working hours',
|
||||||
|
],
|
||||||
|
'start_date': 'ASAP',
|
||||||
|
'status': 'active',
|
||||||
|
'featured': True,
|
||||||
|
'priority': 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Senior Full Stack Developer',
|
||||||
|
'department': 'Engineering',
|
||||||
|
'employment_type': 'full-time',
|
||||||
|
'location_type': 'hybrid',
|
||||||
|
'location': 'Hybrid / Remote',
|
||||||
|
'open_positions': 3,
|
||||||
|
'experience_required': '5+ years',
|
||||||
|
'salary_min': 3000,
|
||||||
|
'salary_max': 5000,
|
||||||
|
'salary_currency': 'USD',
|
||||||
|
'salary_period': 'per month',
|
||||||
|
'salary_additional': '+ Annual bonus + Stock options',
|
||||||
|
'short_description': 'Build scalable applications with cutting-edge technologies.',
|
||||||
|
'about_role': 'We are seeking an experienced Full Stack Developer to join our engineering team. You will be responsible for developing and maintaining our web applications using modern technologies and best practices.',
|
||||||
|
'requirements': [
|
||||||
|
'5+ years of full-stack development experience',
|
||||||
|
'Strong proficiency in React, Next.js, and TypeScript',
|
||||||
|
'Experience with Python/Django or Node.js',
|
||||||
|
'Solid understanding of RESTful APIs and GraphQL',
|
||||||
|
'Experience with SQL and NoSQL databases',
|
||||||
|
'Familiarity with cloud platforms (AWS, GCP, or Azure)',
|
||||||
|
],
|
||||||
|
'responsibilities': [
|
||||||
|
'Develop and maintain web applications',
|
||||||
|
'Write clean, maintainable, and efficient code',
|
||||||
|
'Participate in code reviews',
|
||||||
|
'Collaborate with cross-functional teams',
|
||||||
|
'Optimize applications for performance',
|
||||||
|
'Mentor junior developers',
|
||||||
|
],
|
||||||
|
'qualifications': [
|
||||||
|
'Bachelor\'s degree in Computer Science or related field',
|
||||||
|
'Strong problem-solving skills',
|
||||||
|
'Experience with version control (Git)',
|
||||||
|
'Good understanding of software development lifecycle',
|
||||||
|
],
|
||||||
|
'bonus_points': [
|
||||||
|
'Experience with Docker and Kubernetes',
|
||||||
|
'Knowledge of CI/CD pipelines',
|
||||||
|
'Contributions to open-source projects',
|
||||||
|
'Experience with microservices architecture',
|
||||||
|
],
|
||||||
|
'benefits': [
|
||||||
|
'Competitive salary with stock options',
|
||||||
|
'Flexible work arrangements',
|
||||||
|
'Latest MacBook Pro or custom PC',
|
||||||
|
'Learning and development budget',
|
||||||
|
'Health and dental insurance',
|
||||||
|
'Gym membership',
|
||||||
|
],
|
||||||
|
'start_date': 'Within 1 month',
|
||||||
|
'status': 'active',
|
||||||
|
'featured': True,
|
||||||
|
'priority': 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Digital Marketing Manager',
|
||||||
|
'department': 'Marketing',
|
||||||
|
'employment_type': 'full-time',
|
||||||
|
'location_type': 'on-site',
|
||||||
|
'location': 'New York, NY',
|
||||||
|
'open_positions': 1,
|
||||||
|
'experience_required': '4+ years',
|
||||||
|
'salary_min': 2500,
|
||||||
|
'salary_max': 4000,
|
||||||
|
'salary_currency': 'USD',
|
||||||
|
'salary_period': 'per month',
|
||||||
|
'short_description': 'Lead our digital marketing efforts and grow our online presence.',
|
||||||
|
'about_role': 'We are looking for a creative and data-driven Digital Marketing Manager to lead our marketing initiatives. You will be responsible for developing and executing marketing strategies to increase brand awareness and drive customer acquisition.',
|
||||||
|
'requirements': [
|
||||||
|
'4+ years of digital marketing experience',
|
||||||
|
'Proven track record of successful marketing campaigns',
|
||||||
|
'Strong understanding of SEO, SEM, and social media marketing',
|
||||||
|
'Experience with marketing automation tools',
|
||||||
|
'Excellent analytical and communication skills',
|
||||||
|
'Data-driven mindset with strong analytical abilities',
|
||||||
|
],
|
||||||
|
'responsibilities': [
|
||||||
|
'Develop and execute digital marketing strategies',
|
||||||
|
'Manage social media channels and campaigns',
|
||||||
|
'Oversee content marketing initiatives',
|
||||||
|
'Analyze campaign performance and ROI',
|
||||||
|
'Manage marketing budget',
|
||||||
|
'Collaborate with sales and product teams',
|
||||||
|
],
|
||||||
|
'qualifications': [
|
||||||
|
'Bachelor\'s degree in Marketing or related field',
|
||||||
|
'Experience with Google Analytics and marketing tools',
|
||||||
|
'Strong project management skills',
|
||||||
|
'Creative thinking and problem-solving abilities',
|
||||||
|
],
|
||||||
|
'bonus_points': [
|
||||||
|
'Experience with B2B marketing',
|
||||||
|
'Knowledge of marketing automation platforms',
|
||||||
|
'Video production skills',
|
||||||
|
'Experience with influencer marketing',
|
||||||
|
],
|
||||||
|
'benefits': [
|
||||||
|
'Competitive salary package',
|
||||||
|
'Marketing conferences and events budget',
|
||||||
|
'Professional development opportunities',
|
||||||
|
'Health insurance',
|
||||||
|
'Paid time off',
|
||||||
|
],
|
||||||
|
'start_date': 'ASAP',
|
||||||
|
'status': 'active',
|
||||||
|
'featured': False,
|
||||||
|
'priority': 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Data Analyst',
|
||||||
|
'department': 'Analytics',
|
||||||
|
'employment_type': 'full-time',
|
||||||
|
'location_type': 'remote',
|
||||||
|
'location': 'Remote',
|
||||||
|
'open_positions': 2,
|
||||||
|
'experience_required': '2+ years',
|
||||||
|
'salary_min': 2000,
|
||||||
|
'salary_max': 3500,
|
||||||
|
'salary_currency': 'USD',
|
||||||
|
'salary_period': 'per month',
|
||||||
|
'short_description': 'Turn data into actionable insights to drive business decisions.',
|
||||||
|
'about_role': 'We are seeking a Data Analyst to join our analytics team. You will be responsible for analyzing complex datasets, creating reports, and providing insights to support business decision-making.',
|
||||||
|
'requirements': [
|
||||||
|
'2+ years of data analysis experience',
|
||||||
|
'Proficiency in SQL and data visualization tools',
|
||||||
|
'Experience with Python or R for data analysis',
|
||||||
|
'Strong statistical and analytical skills',
|
||||||
|
'Ability to communicate insights to non-technical stakeholders',
|
||||||
|
],
|
||||||
|
'responsibilities': [
|
||||||
|
'Analyze large datasets to identify trends and patterns',
|
||||||
|
'Create dashboards and reports',
|
||||||
|
'Collaborate with stakeholders to understand data needs',
|
||||||
|
'Perform statistical analysis',
|
||||||
|
'Present findings to leadership team',
|
||||||
|
],
|
||||||
|
'qualifications': [
|
||||||
|
'Bachelor\'s degree in Statistics, Mathematics, or related field',
|
||||||
|
'Experience with Tableau, Power BI, or similar tools',
|
||||||
|
'Strong attention to detail',
|
||||||
|
'Excellent problem-solving skills',
|
||||||
|
],
|
||||||
|
'bonus_points': [
|
||||||
|
'Experience with machine learning',
|
||||||
|
'Knowledge of big data technologies',
|
||||||
|
'Experience with A/B testing',
|
||||||
|
],
|
||||||
|
'benefits': [
|
||||||
|
'Remote work',
|
||||||
|
'Competitive compensation',
|
||||||
|
'Learning and development budget',
|
||||||
|
'Health insurance',
|
||||||
|
'Flexible hours',
|
||||||
|
],
|
||||||
|
'start_date': 'Within 2 weeks',
|
||||||
|
'status': 'active',
|
||||||
|
'featured': False,
|
||||||
|
'priority': 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Product Manager',
|
||||||
|
'department': 'Product',
|
||||||
|
'employment_type': 'full-time',
|
||||||
|
'location_type': 'hybrid',
|
||||||
|
'location': 'San Francisco, CA / Remote',
|
||||||
|
'open_positions': 1,
|
||||||
|
'experience_required': '5+ years',
|
||||||
|
'salary_min': 4000,
|
||||||
|
'salary_max': 6000,
|
||||||
|
'salary_currency': 'USD',
|
||||||
|
'salary_period': 'per month',
|
||||||
|
'salary_additional': '+ Stock options + Annual bonus',
|
||||||
|
'short_description': 'Lead product strategy and development for our flagship products.',
|
||||||
|
'about_role': 'We are looking for an experienced Product Manager to drive the vision and execution of our products. You will work closely with engineering, design, and marketing teams to deliver exceptional products that delight our customers.',
|
||||||
|
'requirements': [
|
||||||
|
'5+ years of product management experience',
|
||||||
|
'Proven track record of successful product launches',
|
||||||
|
'Strong understanding of agile methodologies',
|
||||||
|
'Excellent communication and leadership skills',
|
||||||
|
'Data-driven decision-making approach',
|
||||||
|
'Experience with product analytics tools',
|
||||||
|
],
|
||||||
|
'responsibilities': [
|
||||||
|
'Define product vision and strategy',
|
||||||
|
'Create and maintain product roadmap',
|
||||||
|
'Gather and prioritize requirements',
|
||||||
|
'Work with engineering team on implementation',
|
||||||
|
'Conduct market research and competitive analysis',
|
||||||
|
'Analyze product metrics and user feedback',
|
||||||
|
],
|
||||||
|
'qualifications': [
|
||||||
|
'Bachelor\'s degree (MBA preferred)',
|
||||||
|
'Strong analytical and problem-solving skills',
|
||||||
|
'Experience with product management tools',
|
||||||
|
'Understanding of UX principles',
|
||||||
|
],
|
||||||
|
'bonus_points': [
|
||||||
|
'Technical background',
|
||||||
|
'Experience in SaaS products',
|
||||||
|
'Knowledge of growth strategies',
|
||||||
|
],
|
||||||
|
'benefits': [
|
||||||
|
'Competitive salary with equity',
|
||||||
|
'Hybrid work model',
|
||||||
|
'Professional development budget',
|
||||||
|
'Health and wellness benefits',
|
||||||
|
'Team offsites and events',
|
||||||
|
],
|
||||||
|
'start_date': 'Within 1 month',
|
||||||
|
'status': 'active',
|
||||||
|
'featured': True,
|
||||||
|
'priority': 8,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for job_data in jobs_data:
|
||||||
|
job, created = JobPosition.objects.get_or_create(
|
||||||
|
slug=job_data['title'].lower().replace(' ', '-').replace('/', '-'),
|
||||||
|
defaults=job_data
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'✓ Created job: {job.title}')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'- Job already exists: {job.title}')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'\nSuccessfully created {created_count} job position(s)!'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
88
gnx-react/backend/career/migrations/0001_initial.py
Normal file
88
gnx-react/backend/career/migrations/0001_initial.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2025-10-07 14:45
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='JobPosition',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(help_text='Job title', max_length=255)),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=255, unique=True)),
|
||||||
|
('department', models.CharField(blank=True, help_text='Department or category', max_length=100)),
|
||||||
|
('employment_type', models.CharField(choices=[('full-time', 'Full Time'), ('part-time', 'Part Time'), ('contract', 'Contract'), ('internship', 'Internship'), ('remote', 'Remote')], default='full-time', max_length=20)),
|
||||||
|
('location_type', models.CharField(choices=[('remote', 'Remote'), ('on-site', 'On-site'), ('hybrid', 'Hybrid')], default='remote', max_length=20)),
|
||||||
|
('location', models.CharField(default='Remote', help_text='Work location', max_length=255)),
|
||||||
|
('open_positions', models.PositiveIntegerField(default=1, help_text='Number of open positions')),
|
||||||
|
('experience_required', models.CharField(blank=True, help_text='e.g., 3+ years', max_length=100)),
|
||||||
|
('salary_min', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('salary_max', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('salary_currency', models.CharField(default='USD', max_length=10)),
|
||||||
|
('salary_period', models.CharField(default='per month', help_text='e.g., per month, per year', max_length=20)),
|
||||||
|
('salary_additional', models.TextField(blank=True, help_text='Additional salary info like bonuses, benefits')),
|
||||||
|
('short_description', models.TextField(blank=True, help_text='Brief description for listing page')),
|
||||||
|
('about_role', models.TextField(blank=True, help_text='About this role / Who we are')),
|
||||||
|
('requirements', models.JSONField(blank=True, default=list, help_text='List of requirements')),
|
||||||
|
('responsibilities', models.JSONField(blank=True, default=list, help_text='List of responsibilities')),
|
||||||
|
('qualifications', models.JSONField(blank=True, default=list, help_text='List of qualifications')),
|
||||||
|
('bonus_points', models.JSONField(blank=True, default=list, help_text='Nice to have skills')),
|
||||||
|
('benefits', models.JSONField(blank=True, default=list, help_text='What you get')),
|
||||||
|
('start_date', models.CharField(default='ASAP', help_text='Expected start date', max_length=100)),
|
||||||
|
('posted_date', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_date', models.DateTimeField(auto_now=True)),
|
||||||
|
('deadline', models.DateTimeField(blank=True, help_text='Application deadline', null=True)),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed'), ('draft', 'Draft')], default='active', max_length=20)),
|
||||||
|
('featured', models.BooleanField(default=False, help_text='Feature this job on homepage')),
|
||||||
|
('priority', models.IntegerField(default=0, help_text='Higher number = higher priority in listing')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Job Position',
|
||||||
|
'verbose_name_plural': 'Job Positions',
|
||||||
|
'ordering': ['-priority', '-posted_date'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='JobApplication',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('first_name', models.CharField(max_length=100)),
|
||||||
|
('last_name', models.CharField(max_length=100)),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('phone', models.CharField(blank=True, max_length=20)),
|
||||||
|
('current_position', models.CharField(blank=True, help_text='Current job title', max_length=255)),
|
||||||
|
('current_company', models.CharField(blank=True, max_length=255)),
|
||||||
|
('years_of_experience', models.CharField(blank=True, max_length=50)),
|
||||||
|
('cover_letter', models.TextField(blank=True, help_text='Cover letter or message')),
|
||||||
|
('resume', models.FileField(help_text='Upload your resume (PDF, DOC, DOCX)', upload_to='career/resumes/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx'])])),
|
||||||
|
('portfolio_url', models.URLField(blank=True, help_text='Link to portfolio or LinkedIn')),
|
||||||
|
('linkedin_url', models.URLField(blank=True)),
|
||||||
|
('github_url', models.URLField(blank=True)),
|
||||||
|
('website_url', models.URLField(blank=True)),
|
||||||
|
('available_from', models.DateField(blank=True, help_text='When can you start?', null=True)),
|
||||||
|
('notice_period', models.CharField(blank=True, help_text='Notice period if applicable', max_length=100)),
|
||||||
|
('expected_salary', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('salary_currency', models.CharField(default='USD', max_length=10)),
|
||||||
|
('status', models.CharField(choices=[('new', 'New'), ('reviewing', 'Reviewing'), ('shortlisted', 'Shortlisted'), ('interviewed', 'Interviewed'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='new', max_length=20)),
|
||||||
|
('applied_date', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_date', models.DateTimeField(auto_now=True)),
|
||||||
|
('notes', models.TextField(blank=True, help_text='Internal notes (not visible to applicant)')),
|
||||||
|
('consent', models.BooleanField(default=False, help_text='Consent to data processing')),
|
||||||
|
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='career.jobposition')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Job Application',
|
||||||
|
'verbose_name_plural': 'Job Applications',
|
||||||
|
'ordering': ['-applied_date'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
166
gnx-react/backend/career/models.py
Normal file
166
gnx-react/backend/career/models.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.core.validators import FileExtensionValidator
|
||||||
|
|
||||||
|
|
||||||
|
class JobPosition(models.Model):
|
||||||
|
"""Model for job positions/openings"""
|
||||||
|
|
||||||
|
EMPLOYMENT_TYPE_CHOICES = [
|
||||||
|
('full-time', 'Full Time'),
|
||||||
|
('part-time', 'Part Time'),
|
||||||
|
('contract', 'Contract'),
|
||||||
|
('internship', 'Internship'),
|
||||||
|
('remote', 'Remote'),
|
||||||
|
]
|
||||||
|
|
||||||
|
LOCATION_TYPE_CHOICES = [
|
||||||
|
('remote', 'Remote'),
|
||||||
|
('on-site', 'On-site'),
|
||||||
|
('hybrid', 'Hybrid'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('active', 'Active'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
('draft', 'Draft'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Basic Information
|
||||||
|
title = models.CharField(max_length=255, help_text="Job title")
|
||||||
|
slug = models.SlugField(max_length=255, unique=True, blank=True)
|
||||||
|
department = models.CharField(max_length=100, blank=True, help_text="Department or category")
|
||||||
|
|
||||||
|
# Employment Details
|
||||||
|
employment_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=EMPLOYMENT_TYPE_CHOICES,
|
||||||
|
default='full-time'
|
||||||
|
)
|
||||||
|
location_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=LOCATION_TYPE_CHOICES,
|
||||||
|
default='remote'
|
||||||
|
)
|
||||||
|
location = models.CharField(max_length=255, default='Remote', help_text="Work location")
|
||||||
|
|
||||||
|
# Position Details
|
||||||
|
open_positions = models.PositiveIntegerField(default=1, help_text="Number of open positions")
|
||||||
|
experience_required = models.CharField(max_length=100, blank=True, help_text="e.g., 3+ years")
|
||||||
|
|
||||||
|
# Salary Information
|
||||||
|
salary_min = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
salary_max = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
salary_currency = models.CharField(max_length=10, default='USD')
|
||||||
|
salary_period = models.CharField(max_length=20, default='per month', help_text="e.g., per month, per year")
|
||||||
|
salary_additional = models.TextField(blank=True, help_text="Additional salary info like bonuses, benefits")
|
||||||
|
|
||||||
|
# Job Description
|
||||||
|
short_description = models.TextField(blank=True, help_text="Brief description for listing page")
|
||||||
|
about_role = models.TextField(blank=True, help_text="About this role / Who we are")
|
||||||
|
requirements = models.JSONField(default=list, blank=True, help_text="List of requirements")
|
||||||
|
responsibilities = models.JSONField(default=list, blank=True, help_text="List of responsibilities")
|
||||||
|
qualifications = models.JSONField(default=list, blank=True, help_text="List of qualifications")
|
||||||
|
bonus_points = models.JSONField(default=list, blank=True, help_text="Nice to have skills")
|
||||||
|
benefits = models.JSONField(default=list, blank=True, help_text="What you get")
|
||||||
|
|
||||||
|
# Dates and Status
|
||||||
|
start_date = models.CharField(max_length=100, default='ASAP', help_text="Expected start date")
|
||||||
|
posted_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
|
deadline = models.DateTimeField(null=True, blank=True, help_text="Application deadline")
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active')
|
||||||
|
|
||||||
|
# SEO and Metadata
|
||||||
|
featured = models.BooleanField(default=False, help_text="Feature this job on homepage")
|
||||||
|
priority = models.IntegerField(default=0, help_text="Higher number = higher priority in listing")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-priority', '-posted_date']
|
||||||
|
verbose_name = 'Job Position'
|
||||||
|
verbose_name_plural = 'Job Positions'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} ({self.open_positions} positions)"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.title)
|
||||||
|
# Ensure unique slug
|
||||||
|
original_slug = self.slug
|
||||||
|
counter = 1
|
||||||
|
while JobPosition.objects.filter(slug=self.slug).exists():
|
||||||
|
self.slug = f"{original_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class JobApplication(models.Model):
|
||||||
|
"""Model for job applications"""
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('new', 'New'),
|
||||||
|
('reviewing', 'Reviewing'),
|
||||||
|
('shortlisted', 'Shortlisted'),
|
||||||
|
('interviewed', 'Interviewed'),
|
||||||
|
('accepted', 'Accepted'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Related Job
|
||||||
|
job = models.ForeignKey(JobPosition, on_delete=models.CASCADE, related_name='applications')
|
||||||
|
|
||||||
|
# Applicant Information
|
||||||
|
first_name = models.CharField(max_length=100)
|
||||||
|
last_name = models.CharField(max_length=100)
|
||||||
|
email = models.EmailField()
|
||||||
|
phone = models.CharField(max_length=20, blank=True)
|
||||||
|
|
||||||
|
# Professional Information
|
||||||
|
current_position = models.CharField(max_length=255, blank=True, help_text="Current job title")
|
||||||
|
current_company = models.CharField(max_length=255, blank=True)
|
||||||
|
years_of_experience = models.CharField(max_length=50, blank=True)
|
||||||
|
|
||||||
|
# Application Details
|
||||||
|
cover_letter = models.TextField(blank=True, help_text="Cover letter or message")
|
||||||
|
resume = models.FileField(
|
||||||
|
upload_to='career/resumes/%Y/%m/',
|
||||||
|
validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx'])],
|
||||||
|
help_text="Upload your resume (PDF, DOC, DOCX)"
|
||||||
|
)
|
||||||
|
portfolio_url = models.URLField(blank=True, help_text="Link to portfolio or LinkedIn")
|
||||||
|
|
||||||
|
# Additional Information
|
||||||
|
linkedin_url = models.URLField(blank=True)
|
||||||
|
github_url = models.URLField(blank=True)
|
||||||
|
website_url = models.URLField(blank=True)
|
||||||
|
|
||||||
|
# Availability
|
||||||
|
available_from = models.DateField(null=True, blank=True, help_text="When can you start?")
|
||||||
|
notice_period = models.CharField(max_length=100, blank=True, help_text="Notice period if applicable")
|
||||||
|
|
||||||
|
# Salary Expectations
|
||||||
|
expected_salary = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
salary_currency = models.CharField(max_length=10, default='USD')
|
||||||
|
|
||||||
|
# Application Metadata
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new')
|
||||||
|
applied_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
|
notes = models.TextField(blank=True, help_text="Internal notes (not visible to applicant)")
|
||||||
|
|
||||||
|
# Privacy
|
||||||
|
consent = models.BooleanField(default=False, help_text="Consent to data processing")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-applied_date']
|
||||||
|
verbose_name = 'Job Application'
|
||||||
|
verbose_name_plural = 'Job Applications'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.first_name} {self.last_name} - {self.job.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
131
gnx-react/backend/career/serializers.py
Normal file
131
gnx-react/backend/career/serializers.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import JobPosition, JobApplication
|
||||||
|
|
||||||
|
|
||||||
|
class JobPositionListSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for job position list view"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = JobPosition
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'department',
|
||||||
|
'employment_type',
|
||||||
|
'location_type',
|
||||||
|
'location',
|
||||||
|
'open_positions',
|
||||||
|
'short_description',
|
||||||
|
'salary_min',
|
||||||
|
'salary_max',
|
||||||
|
'salary_currency',
|
||||||
|
'salary_period',
|
||||||
|
'posted_date',
|
||||||
|
'status',
|
||||||
|
'featured',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class JobPositionDetailSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for job position detail view"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = JobPosition
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'department',
|
||||||
|
'employment_type',
|
||||||
|
'location_type',
|
||||||
|
'location',
|
||||||
|
'open_positions',
|
||||||
|
'experience_required',
|
||||||
|
'salary_min',
|
||||||
|
'salary_max',
|
||||||
|
'salary_currency',
|
||||||
|
'salary_period',
|
||||||
|
'salary_additional',
|
||||||
|
'short_description',
|
||||||
|
'about_role',
|
||||||
|
'requirements',
|
||||||
|
'responsibilities',
|
||||||
|
'qualifications',
|
||||||
|
'bonus_points',
|
||||||
|
'benefits',
|
||||||
|
'start_date',
|
||||||
|
'posted_date',
|
||||||
|
'updated_date',
|
||||||
|
'deadline',
|
||||||
|
'status',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class JobApplicationSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for job application submission"""
|
||||||
|
|
||||||
|
job_title = serializers.CharField(source='job.title', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = JobApplication
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'job',
|
||||||
|
'job_title',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'current_position',
|
||||||
|
'current_company',
|
||||||
|
'years_of_experience',
|
||||||
|
'cover_letter',
|
||||||
|
'resume',
|
||||||
|
'portfolio_url',
|
||||||
|
'linkedin_url',
|
||||||
|
'github_url',
|
||||||
|
'website_url',
|
||||||
|
'available_from',
|
||||||
|
'notice_period',
|
||||||
|
'expected_salary',
|
||||||
|
'salary_currency',
|
||||||
|
'consent',
|
||||||
|
'applied_date',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'applied_date', 'job_title']
|
||||||
|
|
||||||
|
def validate_resume(self, value):
|
||||||
|
"""Validate resume file size"""
|
||||||
|
if value.size > 5 * 1024 * 1024: # 5MB
|
||||||
|
raise serializers.ValidationError("Resume file size cannot exceed 5MB")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_consent(self, value):
|
||||||
|
"""Ensure user has given consent"""
|
||||||
|
if not value:
|
||||||
|
raise serializers.ValidationError("You must consent to data processing to apply")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class JobApplicationListSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for listing job applications (admin view)"""
|
||||||
|
|
||||||
|
job_title = serializers.CharField(source='job.title', read_only=True)
|
||||||
|
full_name = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = JobApplication
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'job',
|
||||||
|
'job_title',
|
||||||
|
'full_name',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'status',
|
||||||
|
'applied_date',
|
||||||
|
'resume',
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #4A90E2;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.details {
|
||||||
|
background-color: white;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-left: 4px solid #4A90E2;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Application Received</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<p>Dear <strong>{{ applicant_name }}</strong>,</p>
|
||||||
|
|
||||||
|
<p>Thank you for applying for the <strong>{{ job_title }}</strong> position at GNX!</p>
|
||||||
|
|
||||||
|
<p>We have received your application and our team will review it carefully. We appreciate your interest in joining our team.</p>
|
||||||
|
|
||||||
|
<div class="details">
|
||||||
|
<h3>Application Details</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Position:</strong> {{ job_title }}</li>
|
||||||
|
<li><strong>Location:</strong> {{ job_location }}</li>
|
||||||
|
<li><strong>Applied on:</strong> {{ application_date|date:"F d, Y" }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>What happens next?</h3>
|
||||||
|
<p>Our hiring team will review your application and resume. If your qualifications match our requirements, we will contact you within 1-2 weeks to discuss the next steps.</p>
|
||||||
|
|
||||||
|
<p>If you have any questions, please don't hesitate to reach out to us.</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br><strong>The GNX Team</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated message. Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
Dear {{ applicant_name }},
|
||||||
|
|
||||||
|
Thank you for applying for the {{ job_title }} position at GNX!
|
||||||
|
|
||||||
|
We have received your application and our team will review it carefully. We appreciate your interest in joining our team.
|
||||||
|
|
||||||
|
Application Details:
|
||||||
|
- Position: {{ job_title }}
|
||||||
|
- Location: {{ job_location }}
|
||||||
|
- Applied on: {{ application_date|date:"F d, Y" }}
|
||||||
|
|
||||||
|
What happens next?
|
||||||
|
Our hiring team will review your application and resume. If your qualifications match our requirements, we will contact you within 1-2 weeks to discuss the next steps.
|
||||||
|
|
||||||
|
If you have any questions, please don't hesitate to reach out to us.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The GNX Team
|
||||||
|
|
||||||
|
---
|
||||||
|
This is an automated message. Please do not reply to this email.
|
||||||
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background-color: #2C3E50;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
background-color: white;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 15px 0;
|
||||||
|
border-left: 4px solid #3498db;
|
||||||
|
}
|
||||||
|
.section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #2C3E50;
|
||||||
|
}
|
||||||
|
.info-row {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.cover-letter {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.links a {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 5px 10px 5px 0;
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🎯 New Job Application</h1>
|
||||||
|
<p>{{ job_title }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="section">
|
||||||
|
<h3>👤 Applicant Information</h3>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Name:</span> {{ applicant_name }}
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Email:</span> <a href="mailto:{{ applicant_email }}">{{ applicant_email }}</a>
|
||||||
|
</div>
|
||||||
|
{% if applicant_phone %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Phone:</span> {{ applicant_phone }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>💼 Professional Information</h3>
|
||||||
|
{% if current_position %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Current Position:</span> {{ current_position }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_company %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Current Company:</span> {{ current_company }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if years_of_experience %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Years of Experience:</span> {{ years_of_experience }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>📋 Application Details</h3>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Position Applied:</span> {{ job_title }}
|
||||||
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Application Date:</span> {{ application_date|date:"F d, Y at h:i A" }}
|
||||||
|
</div>
|
||||||
|
{% if expected_salary %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Expected Salary:</span> {{ expected_salary }} {{ salary_currency }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if available_from %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Available From:</span> {{ available_from|date:"F d, Y" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if notice_period %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="label">Notice Period:</span> {{ notice_period }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if portfolio_url or linkedin_url or github_url or website_url %}
|
||||||
|
<div class="section">
|
||||||
|
<h3>🔗 Links</h3>
|
||||||
|
<div class="links">
|
||||||
|
{% if portfolio_url %}<a href="{{ portfolio_url }}" target="_blank">📁 Portfolio</a>{% endif %}
|
||||||
|
{% if linkedin_url %}<a href="{{ linkedin_url }}" target="_blank">💼 LinkedIn</a>{% endif %}
|
||||||
|
{% if github_url %}<a href="{{ github_url }}" target="_blank">💻 GitHub</a>{% endif %}
|
||||||
|
{% if website_url %}<a href="{{ website_url }}" target="_blank">🌐 Website</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if cover_letter %}
|
||||||
|
<div class="section">
|
||||||
|
<h3>✉️ Cover Letter</h3>
|
||||||
|
<div class="cover-letter">{{ cover_letter }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Resume is attached to this email.</strong></p>
|
||||||
|
<p>Please log in to the admin panel to review the full application and update its status.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
New Job Application Received
|
||||||
|
|
||||||
|
A new application has been submitted for the {{ job_title }} position.
|
||||||
|
|
||||||
|
Applicant Information:
|
||||||
|
--------------------
|
||||||
|
Name: {{ applicant_name }}
|
||||||
|
Email: {{ applicant_email }}
|
||||||
|
Phone: {{ applicant_phone }}
|
||||||
|
|
||||||
|
Professional Information:
|
||||||
|
-----------------------
|
||||||
|
Current Position: {{ current_position }}
|
||||||
|
Current Company: {{ current_company }}
|
||||||
|
Years of Experience: {{ years_of_experience }}
|
||||||
|
|
||||||
|
Application Details:
|
||||||
|
------------------
|
||||||
|
Applied for: {{ job_title }}
|
||||||
|
Application Date: {{ application_date|date:"F d, Y at h:i A" }}
|
||||||
|
|
||||||
|
{% if expected_salary %}
|
||||||
|
Expected Salary: {{ expected_salary }} {{ salary_currency }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if available_from %}
|
||||||
|
Available From: {{ available_from|date:"F d, Y" }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if notice_period %}
|
||||||
|
Notice Period: {{ notice_period }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
Links:
|
||||||
|
------
|
||||||
|
{% if portfolio_url %}Portfolio: {{ portfolio_url }}{% endif %}
|
||||||
|
{% if linkedin_url %}LinkedIn: {{ linkedin_url }}{% endif %}
|
||||||
|
{% if github_url %}GitHub: {{ github_url }}{% endif %}
|
||||||
|
{% if website_url %}Website: {{ website_url }}{% endif %}
|
||||||
|
|
||||||
|
Cover Letter:
|
||||||
|
------------
|
||||||
|
{{ cover_letter }}
|
||||||
|
|
||||||
|
---
|
||||||
|
Resume is attached to this email.
|
||||||
|
Please log in to the admin panel to review the full application.
|
||||||
|
|
||||||
4
gnx-react/backend/career/tests.py
Normal file
4
gnx-react/backend/career/tests.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
|
|
||||||
12
gnx-react/backend/career/urls.py
Normal file
12
gnx-react/backend/career/urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import JobPositionViewSet, JobApplicationViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'jobs', JobPositionViewSet, basename='job')
|
||||||
|
router.register(r'applications', JobApplicationViewSet, basename='application')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
133
gnx-react/backend/career/views.py
Normal file
133
gnx-react/backend/career/views.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from rest_framework import viewsets, status, filters
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .models import JobPosition, JobApplication
|
||||||
|
from .serializers import (
|
||||||
|
JobPositionListSerializer,
|
||||||
|
JobPositionDetailSerializer,
|
||||||
|
JobApplicationSerializer,
|
||||||
|
JobApplicationListSerializer
|
||||||
|
)
|
||||||
|
from .email_service import CareerEmailService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class JobPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for job positions
|
||||||
|
GET /api/career/jobs/ - List all active job positions
|
||||||
|
GET /api/career/jobs/{slug}/ - Get job position by slug
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = JobPosition.objects.filter(status='active')
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
lookup_field = 'slug'
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['department', 'employment_type', 'location_type', 'featured']
|
||||||
|
search_fields = ['title', 'department', 'location', 'short_description']
|
||||||
|
ordering_fields = ['posted_date', 'priority', 'title']
|
||||||
|
ordering = ['-priority', '-posted_date']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
return JobPositionDetailSerializer
|
||||||
|
return JobPositionListSerializer
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def featured(self, request):
|
||||||
|
"""Get featured job positions"""
|
||||||
|
featured_jobs = self.queryset.filter(featured=True)
|
||||||
|
serializer = self.get_serializer(featured_jobs, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def applications_count(self, request, slug=None):
|
||||||
|
"""Get number of applications for a job"""
|
||||||
|
job = self.get_object()
|
||||||
|
count = job.applications.count()
|
||||||
|
return Response({'count': count})
|
||||||
|
|
||||||
|
|
||||||
|
class JobApplicationViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for job applications
|
||||||
|
POST /api/career/applications/ - Submit a job application
|
||||||
|
GET /api/career/applications/ - List applications (admin only)
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = JobApplication.objects.all()
|
||||||
|
serializer_class = JobApplicationSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
filterset_fields = ['job', 'status']
|
||||||
|
search_fields = ['first_name', 'last_name', 'email', 'job__title']
|
||||||
|
ordering_fields = ['applied_date', 'status']
|
||||||
|
ordering = ['-applied_date']
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
"""
|
||||||
|
Allow anyone to create (submit) applications
|
||||||
|
Only admins can list/view/update/delete applications
|
||||||
|
"""
|
||||||
|
if self.action == 'create':
|
||||||
|
return [AllowAny()]
|
||||||
|
return [IsAdminUser()]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'list':
|
||||||
|
return JobApplicationListSerializer
|
||||||
|
return JobApplicationSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""Submit a job application"""
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save the application
|
||||||
|
application = serializer.save()
|
||||||
|
|
||||||
|
# Send email notifications
|
||||||
|
email_service = CareerEmailService()
|
||||||
|
email_service.send_application_confirmation(application)
|
||||||
|
email_service.send_application_notification_to_admin(application)
|
||||||
|
|
||||||
|
logger.info(f"New job application received: {application.full_name} for {application.job.title}")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'message': 'Application submitted successfully',
|
||||||
|
'data': serializer.data
|
||||||
|
},
|
||||||
|
status=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error submitting job application: {str(e)}", exc_info=True)
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to submit application. Please try again.'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def update_status(self, request, pk=None):
|
||||||
|
"""Update application status"""
|
||||||
|
application = self.get_object()
|
||||||
|
new_status = request.data.get('status')
|
||||||
|
|
||||||
|
if new_status not in dict(JobApplication.STATUS_CHOICES):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid status'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
application.status = new_status
|
||||||
|
application.save()
|
||||||
|
|
||||||
|
serializer = self.get_serializer(application)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -50,6 +50,8 @@ INSTALLED_APPS = [
|
|||||||
'contact',
|
'contact',
|
||||||
'services',
|
'services',
|
||||||
'about',
|
'about',
|
||||||
|
'career',
|
||||||
|
'support',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -197,6 +199,12 @@ EMAIL_TIMEOUT = config('EMAIL_TIMEOUT', default=30, cast=int)
|
|||||||
# Company email for contact form notifications
|
# Company email for contact form notifications
|
||||||
COMPANY_EMAIL = config('COMPANY_EMAIL')
|
COMPANY_EMAIL = config('COMPANY_EMAIL')
|
||||||
|
|
||||||
|
# Support email for ticket notifications
|
||||||
|
SUPPORT_EMAIL = config('SUPPORT_EMAIL', default=config('COMPANY_EMAIL'))
|
||||||
|
|
||||||
|
# Site URL for email links
|
||||||
|
SITE_URL = config('SITE_URL', default='http://localhost:3000')
|
||||||
|
|
||||||
# Email connection settings for production reliability
|
# Email connection settings for production reliability
|
||||||
EMAIL_CONNECTION_TIMEOUT = config('EMAIL_CONNECTION_TIMEOUT', default=10, cast=int)
|
EMAIL_CONNECTION_TIMEOUT = config('EMAIL_CONNECTION_TIMEOUT', default=10, cast=int)
|
||||||
EMAIL_READ_TIMEOUT = config('EMAIL_READ_TIMEOUT', default=10, cast=int)
|
EMAIL_READ_TIMEOUT = config('EMAIL_READ_TIMEOUT', default=10, cast=int)
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ urlpatterns = [
|
|||||||
path('contact/', include('contact.urls')),
|
path('contact/', include('contact.urls')),
|
||||||
path('services/', include('services.urls')),
|
path('services/', include('services.urls')),
|
||||||
path('about/', include('about.urls')),
|
path('about/', include('about.urls')),
|
||||||
|
path('career/', include('career.urls')),
|
||||||
|
path('support/', include('support.urls')),
|
||||||
])),
|
])),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
193
gnx-react/backend/support/README.md
Normal file
193
gnx-react/backend/support/README.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Support Center Module
|
||||||
|
|
||||||
|
Enterprise Support Center for handling customer support tickets, knowledge base articles, and support settings.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Ticket Management**
|
||||||
|
- Create and track support tickets
|
||||||
|
- Multiple ticket types (technical, billing, feature requests, etc.)
|
||||||
|
- Priority and status management
|
||||||
|
- Ticket categories and tags
|
||||||
|
- SLA deadline tracking
|
||||||
|
- Message and activity history
|
||||||
|
|
||||||
|
- **Knowledge Base**
|
||||||
|
- Categorized articles
|
||||||
|
- Search functionality
|
||||||
|
- Featured articles
|
||||||
|
- Article feedback (helpful/not helpful)
|
||||||
|
- View count tracking
|
||||||
|
- Rich content support
|
||||||
|
|
||||||
|
- **Public API**
|
||||||
|
- Create tickets without authentication
|
||||||
|
- Check ticket status by ticket number
|
||||||
|
- Browse knowledge base articles
|
||||||
|
- Search articles
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Run Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Populate Initial Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py populate_support_data
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create:
|
||||||
|
- 5 ticket statuses (Open, In Progress, Pending Response, Resolved, Closed)
|
||||||
|
- 4 ticket priorities (Low, Medium, High, Critical)
|
||||||
|
- 6 ticket categories
|
||||||
|
- 6 knowledge base categories
|
||||||
|
- 6 sample knowledge base articles
|
||||||
|
|
||||||
|
### 3. Admin Access
|
||||||
|
|
||||||
|
Access the Django admin panel to manage:
|
||||||
|
- Support tickets
|
||||||
|
- Ticket categories, statuses, and priorities
|
||||||
|
- Knowledge base categories and articles
|
||||||
|
- Support settings
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Tickets
|
||||||
|
|
||||||
|
- `GET /api/support/tickets/` - List all tickets
|
||||||
|
- `POST /api/support/tickets/` - Create a new ticket
|
||||||
|
- `GET /api/support/tickets/{id}/` - Get ticket details
|
||||||
|
- `POST /api/support/tickets/check-status/` - Check ticket status by ticket number
|
||||||
|
- `POST /api/support/tickets/{id}/add-message/` - Add a message to a ticket
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
|
||||||
|
- `GET /api/support/categories/` - List all ticket categories
|
||||||
|
- `GET /api/support/statuses/` - List all ticket statuses
|
||||||
|
- `GET /api/support/priorities/` - List all ticket priorities
|
||||||
|
|
||||||
|
### Knowledge Base
|
||||||
|
|
||||||
|
- `GET /api/support/knowledge-base/` - List all published articles
|
||||||
|
- `GET /api/support/knowledge-base/{slug}/` - Get article details
|
||||||
|
- `GET /api/support/knowledge-base/featured/` - Get featured articles
|
||||||
|
- `GET /api/support/knowledge-base/by-category/{category_slug}/` - Get articles by category
|
||||||
|
- `POST /api/support/knowledge-base/{slug}/mark-helpful/` - Mark article as helpful/not helpful
|
||||||
|
- `GET /api/support/knowledge-base-categories/` - List all KB categories
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
- `GET /api/support/settings/` - List all active support settings
|
||||||
|
- `GET /api/support/settings/{setting_name}/` - Get specific setting
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### SupportTicket
|
||||||
|
Main model for support tickets with full tracking capabilities.
|
||||||
|
|
||||||
|
### TicketStatus
|
||||||
|
Ticket status options (Open, In Progress, Resolved, etc.)
|
||||||
|
|
||||||
|
### TicketPriority
|
||||||
|
Priority levels with SLA hours (Low, Medium, High, Critical)
|
||||||
|
|
||||||
|
### TicketCategory
|
||||||
|
Categorize tickets for better organization
|
||||||
|
|
||||||
|
### TicketMessage
|
||||||
|
Messages and updates on tickets
|
||||||
|
|
||||||
|
### TicketActivity
|
||||||
|
Audit trail of all ticket changes
|
||||||
|
|
||||||
|
### KnowledgeBaseCategory
|
||||||
|
Categories for knowledge base articles
|
||||||
|
|
||||||
|
### KnowledgeBaseArticle
|
||||||
|
Knowledge base articles with rich content
|
||||||
|
|
||||||
|
### SupportSettings
|
||||||
|
Configurable support center settings
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Create a Ticket
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"title": "Cannot login to my account",
|
||||||
|
"description": "I've been trying to login but getting error 500",
|
||||||
|
"ticket_type": "technical",
|
||||||
|
"user_name": "John Doe",
|
||||||
|
"user_email": "john@example.com",
|
||||||
|
"user_phone": "+1234567890",
|
||||||
|
"company": "Acme Corp",
|
||||||
|
"category": 1 # Technical Support category ID
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post('http://localhost:8000/api/support/tickets/', json=data)
|
||||||
|
ticket = response.json()
|
||||||
|
print(f"Ticket created: {ticket['ticket_number']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Ticket Status
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"ticket_number": "TKT-20231015-ABCDE"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post('http://localhost:8000/api/support/tickets/check-status/', json=data)
|
||||||
|
ticket = response.json()
|
||||||
|
print(f"Status: {ticket['status_name']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Knowledge Base
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
response = requests.get('http://localhost:8000/api/support/knowledge-base/', params={'search': 'login'})
|
||||||
|
articles = response.json()
|
||||||
|
for article in articles:
|
||||||
|
print(f"- {article['title']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
The support center is integrated with the Next.js frontend at `/support-center` with:
|
||||||
|
- Ticket submission form
|
||||||
|
- Knowledge base browser with search
|
||||||
|
- Ticket status checker
|
||||||
|
- Modern, responsive UI
|
||||||
|
|
||||||
|
## Email Notifications
|
||||||
|
|
||||||
|
To enable email notifications for tickets, configure email settings in `settings.py` and implement email templates in `support/templates/support/`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- All endpoints are public (AllowAny permission)
|
||||||
|
- Ticket numbers are randomly generated and hard to guess
|
||||||
|
- Internal notes and messages are hidden from public API
|
||||||
|
- Rate limiting recommended for production
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Live chat integration
|
||||||
|
- [ ] File attachments for tickets
|
||||||
|
- [ ] Email notifications
|
||||||
|
- [ ] Ticket assignment and routing
|
||||||
|
- [ ] SLA breach alerts
|
||||||
|
- [ ] Advanced analytics dashboard
|
||||||
|
- [ ] Webhook notifications
|
||||||
|
|
||||||
BIN
gnx-react/backend/support/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
gnx-react/backend/support/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/support/__pycache__/admin.cpython-312.pyc
Normal file
BIN
gnx-react/backend/support/__pycache__/admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/support/__pycache__/apps.cpython-312.pyc
Normal file
BIN
gnx-react/backend/support/__pycache__/apps.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
gnx-react/backend/support/__pycache__/models.cpython-312.pyc
Normal file
BIN
gnx-react/backend/support/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
gnx-react/backend/support/__pycache__/signals.cpython-312.pyc
Normal file
BIN
gnx-react/backend/support/__pycache__/signals.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/support/__pycache__/urls.cpython-312.pyc
Normal file
BIN
gnx-react/backend/support/__pycache__/urls.cpython-312.pyc
Normal file
Binary file not shown.
BIN
gnx-react/backend/support/__pycache__/views.cpython-312.pyc
Normal file
BIN
gnx-react/backend/support/__pycache__/views.cpython-312.pyc
Normal file
Binary file not shown.
182
gnx-react/backend/support/admin.py
Normal file
182
gnx-react/backend/support/admin.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import (
|
||||||
|
SupportTicket, TicketStatus, TicketPriority, TicketCategory,
|
||||||
|
TicketMessage, TicketActivity, KnowledgeBaseCategory,
|
||||||
|
KnowledgeBaseArticle, SupportSettings, RegisteredEmail
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TicketStatus)
|
||||||
|
class TicketStatusAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'color', 'is_closed', 'is_active', 'display_order']
|
||||||
|
list_filter = ['is_closed', 'is_active']
|
||||||
|
search_fields = ['name']
|
||||||
|
ordering = ['display_order', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TicketPriority)
|
||||||
|
class TicketPriorityAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'level', 'color', 'sla_hours', 'is_active']
|
||||||
|
list_filter = ['is_active']
|
||||||
|
search_fields = ['name']
|
||||||
|
ordering = ['level']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TicketCategory)
|
||||||
|
class TicketCategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'icon', 'color', 'is_active', 'display_order']
|
||||||
|
list_filter = ['is_active']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering = ['display_order', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
class TicketMessageInline(admin.TabularInline):
|
||||||
|
model = TicketMessage
|
||||||
|
extra = 0
|
||||||
|
fields = ['message_type', 'content', 'author_name', 'created_at', 'is_internal']
|
||||||
|
readonly_fields = ['created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class TicketActivityInline(admin.TabularInline):
|
||||||
|
model = TicketActivity
|
||||||
|
extra = 0
|
||||||
|
fields = ['activity_type', 'description', 'user_name', 'created_at']
|
||||||
|
readonly_fields = ['created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SupportTicket)
|
||||||
|
class SupportTicketAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'ticket_number', 'title', 'user_name', 'user_email',
|
||||||
|
'status', 'priority', 'category', 'created_at', 'is_escalated'
|
||||||
|
]
|
||||||
|
list_filter = ['status', 'priority', 'category', 'ticket_type', 'is_escalated', 'created_at']
|
||||||
|
search_fields = ['ticket_number', 'title', 'user_name', 'user_email', 'description']
|
||||||
|
readonly_fields = ['ticket_number', 'created_at', 'updated_at', 'last_activity']
|
||||||
|
inlines = [TicketMessageInline, TicketActivityInline]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Ticket Information', {
|
||||||
|
'fields': ('ticket_number', 'title', 'description', 'ticket_type')
|
||||||
|
}),
|
||||||
|
('User Information', {
|
||||||
|
'fields': ('user_name', 'user_email', 'user_phone', 'company')
|
||||||
|
}),
|
||||||
|
('Ticket Management', {
|
||||||
|
'fields': ('category', 'priority', 'status', 'assigned_to', 'assigned_at')
|
||||||
|
}),
|
||||||
|
('Escalation', {
|
||||||
|
'fields': ('is_escalated', 'escalation_reason'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at', 'closed_at', 'last_activity', 'first_response_at', 'sla_deadline')
|
||||||
|
}),
|
||||||
|
('Additional Information', {
|
||||||
|
'fields': ('tags', 'internal_notes', 'attachments'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TicketMessage)
|
||||||
|
class TicketMessageAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['ticket', 'message_type', 'author_name', 'created_at', 'is_internal', 'is_read']
|
||||||
|
list_filter = ['message_type', 'is_internal', 'is_read', 'created_at']
|
||||||
|
search_fields = ['ticket__ticket_number', 'content', 'author_name', 'author_email']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TicketActivity)
|
||||||
|
class TicketActivityAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['ticket', 'activity_type', 'user_name', 'created_at']
|
||||||
|
list_filter = ['activity_type', 'created_at']
|
||||||
|
search_fields = ['ticket__ticket_number', 'description', 'user_name']
|
||||||
|
readonly_fields = ['created_at']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(KnowledgeBaseCategory)
|
||||||
|
class KnowledgeBaseCategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'slug', 'icon', 'color', 'is_active', 'display_order']
|
||||||
|
list_filter = ['is_active']
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
ordering = ['display_order', 'name']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(KnowledgeBaseArticle)
|
||||||
|
class KnowledgeBaseArticleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'title', 'category', 'is_published', 'is_featured',
|
||||||
|
'view_count', 'helpful_count', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = ['is_published', 'is_featured', 'category', 'created_at']
|
||||||
|
search_fields = ['title', 'content', 'summary', 'keywords']
|
||||||
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
|
readonly_fields = ['view_count', 'helpful_count', 'not_helpful_count', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Article Information', {
|
||||||
|
'fields': ('title', 'slug', 'category', 'content', 'summary')
|
||||||
|
}),
|
||||||
|
('SEO & Metadata', {
|
||||||
|
'fields': ('meta_description', 'keywords')
|
||||||
|
}),
|
||||||
|
('Publishing', {
|
||||||
|
'fields': ('author', 'is_published', 'is_featured', 'published_at')
|
||||||
|
}),
|
||||||
|
('Statistics', {
|
||||||
|
'fields': ('view_count', 'helpful_count', 'not_helpful_count'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SupportSettings)
|
||||||
|
class SupportSettingsAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['setting_name', 'is_active', 'created_at', 'updated_at']
|
||||||
|
list_filter = ['is_active']
|
||||||
|
search_fields = ['setting_name', 'setting_value', 'description']
|
||||||
|
ordering = ['setting_name']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(RegisteredEmail)
|
||||||
|
class RegisteredEmailAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['email', 'company_name', 'contact_name', 'is_active', 'ticket_count', 'last_ticket_date', 'created_at']
|
||||||
|
list_filter = ['is_active', 'created_at', 'last_ticket_date']
|
||||||
|
search_fields = ['email', 'company_name', 'contact_name', 'notes']
|
||||||
|
readonly_fields = ['added_by', 'created_at', 'updated_at', 'last_ticket_date', 'ticket_count']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Email Information', {
|
||||||
|
'fields': ('email', 'is_active')
|
||||||
|
}),
|
||||||
|
('Contact Details', {
|
||||||
|
'fields': ('company_name', 'contact_name', 'notes')
|
||||||
|
}),
|
||||||
|
('Statistics', {
|
||||||
|
'fields': ('ticket_count', 'last_ticket_date'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('System Information', {
|
||||||
|
'fields': ('added_by', 'created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
"""Automatically set added_by to current user if creating new record"""
|
||||||
|
if not change: # If creating new object
|
||||||
|
obj.added_by = request.user
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
10
gnx-react/backend/support/apps.py
Normal file
10
gnx-react/backend/support/apps.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SupportConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'support'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""Import signal handlers when app is ready"""
|
||||||
|
import support.signals
|
||||||
161
gnx-react/backend/support/email_service.py
Normal file
161
gnx-react/backend/support/email_service.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
Email Service for Support Tickets
|
||||||
|
Handles sending email notifications for ticket creation and updates
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SupportEmailService:
|
||||||
|
"""Service for sending support ticket related emails"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_ticket_confirmation_to_user(ticket):
|
||||||
|
"""
|
||||||
|
Send ticket confirmation email to the user who created the ticket
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticket: SupportTicket instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subject = f'Ticket Created: {ticket.ticket_number}'
|
||||||
|
|
||||||
|
# Context for email template
|
||||||
|
context = {
|
||||||
|
'ticket': ticket,
|
||||||
|
'ticket_number': ticket.ticket_number,
|
||||||
|
'user_name': ticket.user_name,
|
||||||
|
'title': ticket.title,
|
||||||
|
'description': ticket.description,
|
||||||
|
'ticket_type': ticket.get_ticket_type_display(),
|
||||||
|
'category': ticket.category.name if ticket.category else 'General',
|
||||||
|
'priority': ticket.priority.name if ticket.priority else 'Medium',
|
||||||
|
'status': ticket.status.name if ticket.status else 'Open',
|
||||||
|
'created_at': ticket.created_at.strftime('%B %d, %Y at %I:%M %p'),
|
||||||
|
'support_url': f'{settings.SITE_URL}/support-center',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render HTML email
|
||||||
|
html_message = render_to_string(
|
||||||
|
'support/ticket_confirmation_user.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create plain text version
|
||||||
|
text_message = render_to_string(
|
||||||
|
'support/ticket_confirmation_user.txt',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[ticket.user_email],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach HTML version
|
||||||
|
email.attach_alternative(html_message, "text/html")
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
|
||||||
|
logger.info(f'Ticket confirmation email sent to user: {ticket.user_email} for ticket {ticket.ticket_number}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send ticket confirmation email to user: {str(e)}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_ticket_notification_to_company(ticket):
|
||||||
|
"""
|
||||||
|
Send ticket notification email to company/support team
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticket: SupportTicket instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
subject = f'New Support Ticket: {ticket.ticket_number} - {ticket.title}'
|
||||||
|
|
||||||
|
# Get company email from settings
|
||||||
|
company_email = getattr(settings, 'SUPPORT_EMAIL', settings.DEFAULT_FROM_EMAIL)
|
||||||
|
|
||||||
|
# Context for email template
|
||||||
|
context = {
|
||||||
|
'ticket': ticket,
|
||||||
|
'ticket_number': ticket.ticket_number,
|
||||||
|
'user_name': ticket.user_name,
|
||||||
|
'user_email': ticket.user_email,
|
||||||
|
'user_phone': ticket.user_phone,
|
||||||
|
'company': ticket.company,
|
||||||
|
'title': ticket.title,
|
||||||
|
'description': ticket.description,
|
||||||
|
'ticket_type': ticket.get_ticket_type_display(),
|
||||||
|
'category': ticket.category.name if ticket.category else 'General',
|
||||||
|
'priority': ticket.priority.name if ticket.priority else 'Medium',
|
||||||
|
'status': ticket.status.name if ticket.status else 'Open',
|
||||||
|
'created_at': ticket.created_at.strftime('%B %d, %Y at %I:%M %p'),
|
||||||
|
'admin_url': f'{settings.SITE_URL}/admin/support/supportticket/{ticket.id}/change/',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render HTML email
|
||||||
|
html_message = render_to_string(
|
||||||
|
'support/ticket_notification_company.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create plain text version
|
||||||
|
text_message = render_to_string(
|
||||||
|
'support/ticket_notification_company.txt',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[company_email],
|
||||||
|
reply_to=[ticket.user_email],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach HTML version
|
||||||
|
email.attach_alternative(html_message, "text/html")
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
|
||||||
|
logger.info(f'Ticket notification email sent to company: {company_email} for ticket {ticket.ticket_number}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send ticket notification email to company: {str(e)}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_ticket_created_emails(ticket):
|
||||||
|
"""
|
||||||
|
Send both user confirmation and company notification emails
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticket: SupportTicket instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with status of both emails
|
||||||
|
"""
|
||||||
|
user_email_sent = SupportEmailService.send_ticket_confirmation_to_user(ticket)
|
||||||
|
company_email_sent = SupportEmailService.send_ticket_notification_to_company(ticket)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'user_email_sent': user_email_sent,
|
||||||
|
'company_email_sent': company_email_sent,
|
||||||
|
}
|
||||||
|
|
||||||
0
gnx-react/backend/support/management/__init__.py
Normal file
0
gnx-react/backend/support/management/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Management command to add registered emails for testing
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from support.models import RegisteredEmail
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Add sample registered emails for testing'
|
||||||
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
# Get the admin user to set as added_by
|
||||||
|
admin_user = User.objects.filter(is_superuser=True).first()
|
||||||
|
|
||||||
|
emails_to_add = [
|
||||||
|
{
|
||||||
|
'email': 'admin@gnxsoft.com',
|
||||||
|
'company_name': 'GNX Software',
|
||||||
|
'contact_name': 'Admin User',
|
||||||
|
'notes': 'Primary admin email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'email': 'support@gnxsoft.com',
|
||||||
|
'company_name': 'GNX Software',
|
||||||
|
'contact_name': 'Support Team',
|
||||||
|
'notes': 'General support email',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for email_data in emails_to_add:
|
||||||
|
registered_email, created = RegisteredEmail.objects.get_or_create(
|
||||||
|
email=email_data['email'],
|
||||||
|
defaults={
|
||||||
|
'company_name': email_data['company_name'],
|
||||||
|
'contact_name': email_data['contact_name'],
|
||||||
|
'notes': email_data['notes'],
|
||||||
|
'added_by': admin_user,
|
||||||
|
'is_active': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'✓ Created registered email: {email_data["email"]}')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'- Email already exists: {email_data["email"]}')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'\n✓ Added {created_count} new registered emails')
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from support.models import (
|
||||||
|
TicketStatus, TicketPriority, TicketCategory,
|
||||||
|
KnowledgeBaseCategory, KnowledgeBaseArticle
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Populate support center with initial data'
|
||||||
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
self.stdout.write(self.style.SUCCESS('Starting to populate support data...'))
|
||||||
|
|
||||||
|
# Create Ticket Statuses
|
||||||
|
self.create_ticket_statuses()
|
||||||
|
|
||||||
|
# Create Ticket Priorities
|
||||||
|
self.create_ticket_priorities()
|
||||||
|
|
||||||
|
# Create Ticket Categories
|
||||||
|
self.create_ticket_categories()
|
||||||
|
|
||||||
|
# Create Knowledge Base Categories
|
||||||
|
self.create_kb_categories()
|
||||||
|
|
||||||
|
# Create Knowledge Base Articles
|
||||||
|
self.create_kb_articles()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('Successfully populated support data!'))
|
||||||
|
|
||||||
|
def create_ticket_statuses(self):
|
||||||
|
self.stdout.write('Creating ticket statuses...')
|
||||||
|
|
||||||
|
statuses = [
|
||||||
|
{'name': 'Open', 'color': '#3b82f6', 'description': 'Ticket has been opened', 'is_closed': False, 'display_order': 1},
|
||||||
|
{'name': 'In Progress', 'color': '#f59e0b', 'description': 'Ticket is being worked on', 'is_closed': False, 'display_order': 2},
|
||||||
|
{'name': 'Pending Response', 'color': '#8b5cf6', 'description': 'Waiting for customer response', 'is_closed': False, 'display_order': 3},
|
||||||
|
{'name': 'Resolved', 'color': '#10b981', 'description': 'Ticket has been resolved', 'is_closed': True, 'display_order': 4},
|
||||||
|
{'name': 'Closed', 'color': '#6b7280', 'description': 'Ticket has been closed', 'is_closed': True, 'display_order': 5},
|
||||||
|
]
|
||||||
|
|
||||||
|
for status_data in statuses:
|
||||||
|
status, created = TicketStatus.objects.get_or_create(
|
||||||
|
name=status_data['name'],
|
||||||
|
defaults=status_data
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' ✓ Created status: {status.name}'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f' - Status already exists: {status.name}')
|
||||||
|
|
||||||
|
def create_ticket_priorities(self):
|
||||||
|
self.stdout.write('Creating ticket priorities...')
|
||||||
|
|
||||||
|
priorities = [
|
||||||
|
{'name': 'Low', 'level': 4, 'color': '#6b7280', 'description': 'Low priority issue', 'sla_hours': 72},
|
||||||
|
{'name': 'Medium', 'level': 3, 'color': '#3b82f6', 'description': 'Medium priority issue', 'sla_hours': 48},
|
||||||
|
{'name': 'High', 'level': 2, 'color': '#f59e0b', 'description': 'High priority issue', 'sla_hours': 24},
|
||||||
|
{'name': 'Critical', 'level': 1, 'color': '#ef4444', 'description': 'Critical issue requiring immediate attention', 'sla_hours': 4},
|
||||||
|
]
|
||||||
|
|
||||||
|
for priority_data in priorities:
|
||||||
|
priority, created = TicketPriority.objects.get_or_create(
|
||||||
|
name=priority_data['name'],
|
||||||
|
defaults=priority_data
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' ✓ Created priority: {priority.name}'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f' - Priority already exists: {priority.name}')
|
||||||
|
|
||||||
|
def create_ticket_categories(self):
|
||||||
|
self.stdout.write('Creating ticket categories...')
|
||||||
|
|
||||||
|
categories = [
|
||||||
|
{'name': 'Technical Support', 'description': 'Technical issues and troubleshooting', 'color': '#3b82f6', 'icon': 'fa-wrench', 'display_order': 1},
|
||||||
|
{'name': 'Billing & Payments', 'description': 'Billing questions and payment issues', 'color': '#10b981', 'icon': 'fa-credit-card', 'display_order': 2},
|
||||||
|
{'name': 'Account Management', 'description': 'Account settings and access issues', 'color': '#8b5cf6', 'icon': 'fa-user-cog', 'display_order': 3},
|
||||||
|
{'name': 'Product Inquiry', 'description': 'Questions about products and features', 'color': '#f59e0b', 'icon': 'fa-box', 'display_order': 4},
|
||||||
|
{'name': 'Bug Reports', 'description': 'Report software bugs and issues', 'color': '#ef4444', 'icon': 'fa-bug', 'display_order': 5},
|
||||||
|
{'name': 'Feature Requests', 'description': 'Request new features or improvements', 'color': '#06b6d4', 'icon': 'fa-lightbulb', 'display_order': 6},
|
||||||
|
]
|
||||||
|
|
||||||
|
for category_data in categories:
|
||||||
|
category, created = TicketCategory.objects.get_or_create(
|
||||||
|
name=category_data['name'],
|
||||||
|
defaults=category_data
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' ✓ Created category: {category.name}'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f' - Category already exists: {category.name}')
|
||||||
|
|
||||||
|
def create_kb_categories(self):
|
||||||
|
self.stdout.write('Creating knowledge base categories...')
|
||||||
|
|
||||||
|
categories = [
|
||||||
|
{'name': 'Getting Started', 'slug': 'getting-started', 'description': 'Learn the basics and get started quickly', 'icon': 'fa-rocket', 'color': '#3b82f6', 'display_order': 1},
|
||||||
|
{'name': 'Account & Billing', 'slug': 'account-billing', 'description': 'Manage your account and billing information', 'icon': 'fa-user-circle', 'color': '#10b981', 'display_order': 2},
|
||||||
|
{'name': 'Technical Documentation', 'slug': 'technical-docs', 'description': 'Technical guides and API documentation', 'icon': 'fa-code', 'color': '#8b5cf6', 'display_order': 3},
|
||||||
|
{'name': 'Troubleshooting', 'slug': 'troubleshooting', 'description': 'Common issues and how to resolve them', 'icon': 'fa-tools', 'color': '#f59e0b', 'display_order': 4},
|
||||||
|
{'name': 'Security & Privacy', 'slug': 'security-privacy', 'description': 'Security features and privacy settings', 'icon': 'fa-shield-alt', 'color': '#ef4444', 'display_order': 5},
|
||||||
|
{'name': 'Best Practices', 'slug': 'best-practices', 'description': 'Tips and best practices for optimal use', 'icon': 'fa-star', 'color': '#daa520', 'display_order': 6},
|
||||||
|
]
|
||||||
|
|
||||||
|
for category_data in categories:
|
||||||
|
category, created = KnowledgeBaseCategory.objects.get_or_create(
|
||||||
|
slug=category_data['slug'],
|
||||||
|
defaults=category_data
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' ✓ Created KB category: {category.name}'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f' - KB category already exists: {category.name}')
|
||||||
|
|
||||||
|
def create_kb_articles(self):
|
||||||
|
self.stdout.write('Creating knowledge base articles...')
|
||||||
|
|
||||||
|
# Get categories
|
||||||
|
getting_started = KnowledgeBaseCategory.objects.filter(slug='getting-started').first()
|
||||||
|
account_billing = KnowledgeBaseCategory.objects.filter(slug='account-billing').first()
|
||||||
|
technical = KnowledgeBaseCategory.objects.filter(slug='technical-docs').first()
|
||||||
|
troubleshooting = KnowledgeBaseCategory.objects.filter(slug='troubleshooting').first()
|
||||||
|
|
||||||
|
articles = [
|
||||||
|
{
|
||||||
|
'title': 'How to Get Started with Our Platform',
|
||||||
|
'slug': 'how-to-get-started',
|
||||||
|
'category': getting_started,
|
||||||
|
'summary': 'A comprehensive guide to help you get started with our platform quickly and easily.',
|
||||||
|
'content': '''<h2>Welcome to Our Platform!</h2>
|
||||||
|
<p>This guide will help you get started with our platform in just a few simple steps.</p>
|
||||||
|
|
||||||
|
<h3>Step 1: Create Your Account</h3>
|
||||||
|
<p>Visit our sign-up page and create your account using your email address or social login.</p>
|
||||||
|
|
||||||
|
<h3>Step 2: Complete Your Profile</h3>
|
||||||
|
<p>Add your company information and customize your profile settings.</p>
|
||||||
|
|
||||||
|
<h3>Step 3: Explore the Dashboard</h3>
|
||||||
|
<p>Familiarize yourself with the main dashboard and available features.</p>
|
||||||
|
|
||||||
|
<h3>Step 4: Start Using Our Services</h3>
|
||||||
|
<p>Begin using our services and tools to achieve your business goals.</p>
|
||||||
|
|
||||||
|
<p>If you need any help, our support team is always here to assist you!</p>''',
|
||||||
|
'is_published': True,
|
||||||
|
'is_featured': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Understanding Your Billing Cycle',
|
||||||
|
'slug': 'understanding-billing-cycle',
|
||||||
|
'category': account_billing,
|
||||||
|
'summary': 'Learn how our billing cycle works and how to manage your payments.',
|
||||||
|
'content': '''<h2>Billing Cycle Overview</h2>
|
||||||
|
<p>Understanding your billing cycle is important for managing your subscription effectively.</p>
|
||||||
|
|
||||||
|
<h3>Monthly Billing</h3>
|
||||||
|
<p>For monthly subscriptions, you'll be charged on the same date each month.</p>
|
||||||
|
|
||||||
|
<h3>Annual Billing</h3>
|
||||||
|
<p>Annual subscriptions offer a discount and are billed once per year.</p>
|
||||||
|
|
||||||
|
<h3>Managing Your Subscription</h3>
|
||||||
|
<p>You can upgrade, downgrade, or cancel your subscription at any time from your account settings.</p>''',
|
||||||
|
'is_published': True,
|
||||||
|
'is_featured': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'API Documentation Overview',
|
||||||
|
'slug': 'api-documentation-overview',
|
||||||
|
'category': technical,
|
||||||
|
'summary': 'Complete guide to our API endpoints and authentication.',
|
||||||
|
'content': '''<h2>API Documentation</h2>
|
||||||
|
<p>Our API provides programmatic access to all platform features.</p>
|
||||||
|
|
||||||
|
<h3>Authentication</h3>
|
||||||
|
<p>All API requests require authentication using an API key.</p>
|
||||||
|
<code>Authorization: Bearer YOUR_API_KEY</code>
|
||||||
|
|
||||||
|
<h3>Rate Limits</h3>
|
||||||
|
<p>Standard accounts are limited to 1000 requests per hour.</p>
|
||||||
|
|
||||||
|
<h3>Response Format</h3>
|
||||||
|
<p>All responses are returned in JSON format.</p>''',
|
||||||
|
'is_published': True,
|
||||||
|
'is_featured': True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Common Login Issues and Solutions',
|
||||||
|
'slug': 'common-login-issues',
|
||||||
|
'category': troubleshooting,
|
||||||
|
'summary': 'Troubleshoot common login problems and learn how to resolve them.',
|
||||||
|
'content': '''<h2>Login Troubleshooting</h2>
|
||||||
|
<p>Having trouble logging in? Here are some common issues and solutions.</p>
|
||||||
|
|
||||||
|
<h3>Forgot Password</h3>
|
||||||
|
<p>Click "Forgot Password" on the login page to reset your password via email.</p>
|
||||||
|
|
||||||
|
<h3>Account Locked</h3>
|
||||||
|
<p>After multiple failed login attempts, your account may be temporarily locked for security.</p>
|
||||||
|
|
||||||
|
<h3>Browser Issues</h3>
|
||||||
|
<p>Clear your browser cache and cookies, or try a different browser.</p>
|
||||||
|
|
||||||
|
<h3>Still Having Issues?</h3>
|
||||||
|
<p>Contact our support team for personalized assistance.</p>''',
|
||||||
|
'is_published': True,
|
||||||
|
'is_featured': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'How to Update Your Payment Method',
|
||||||
|
'slug': 'update-payment-method',
|
||||||
|
'category': account_billing,
|
||||||
|
'summary': 'Step-by-step guide to updating your payment information.',
|
||||||
|
'content': '''<h2>Updating Payment Information</h2>
|
||||||
|
<p>Keep your payment method up to date to avoid service interruptions.</p>
|
||||||
|
|
||||||
|
<h3>Steps to Update</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Go to Account Settings</li>
|
||||||
|
<li>Click on "Billing & Payments"</li>
|
||||||
|
<li>Select "Update Payment Method"</li>
|
||||||
|
<li>Enter your new payment details</li>
|
||||||
|
<li>Click "Save Changes"</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Supported Payment Methods</h3>
|
||||||
|
<p>We accept all major credit cards, PayPal, and bank transfers.</p>''',
|
||||||
|
'is_published': True,
|
||||||
|
'is_featured': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Security Best Practices',
|
||||||
|
'slug': 'security-best-practices',
|
||||||
|
'category': KnowledgeBaseCategory.objects.filter(slug='security-privacy').first(),
|
||||||
|
'summary': 'Essential security practices to keep your account safe.',
|
||||||
|
'content': '''<h2>Account Security</h2>
|
||||||
|
<p>Follow these best practices to keep your account secure.</p>
|
||||||
|
|
||||||
|
<h3>Use Strong Passwords</h3>
|
||||||
|
<p>Create complex passwords with a mix of letters, numbers, and symbols.</p>
|
||||||
|
|
||||||
|
<h3>Enable Two-Factor Authentication</h3>
|
||||||
|
<p>Add an extra layer of security with 2FA.</p>
|
||||||
|
|
||||||
|
<h3>Regular Security Audits</h3>
|
||||||
|
<p>Review your account activity regularly for any suspicious behavior.</p>
|
||||||
|
|
||||||
|
<h3>Keep Software Updated</h3>
|
||||||
|
<p>Always use the latest version of our software for the best security.</p>''',
|
||||||
|
'is_published': True,
|
||||||
|
'is_featured': True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for article_data in articles:
|
||||||
|
if article_data['category']:
|
||||||
|
article, created = KnowledgeBaseArticle.objects.get_or_create(
|
||||||
|
slug=article_data['slug'],
|
||||||
|
defaults=article_data
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' ✓ Created article: {article.title}'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f' - Article already exists: {article.title}')
|
||||||
|
|
||||||
183
gnx-react/backend/support/migrations/0001_initial.py
Normal file
183
gnx-react/backend/support/migrations/0001_initial.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2025-10-07 15:42
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SupportSettings',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('setting_name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('setting_value', models.TextField()),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Support Settings',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TicketCategory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('color', models.CharField(default='#667eea', help_text='Hex color code', max_length=7)),
|
||||||
|
('icon', models.CharField(default='fa-question-circle', help_text='FontAwesome icon class', max_length=50)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('display_order', models.PositiveIntegerField(default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Ticket Categories',
|
||||||
|
'ordering': ['display_order', 'name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TicketPriority',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=50, unique=True)),
|
||||||
|
('level', models.PositiveIntegerField(help_text='Lower number = higher priority', unique=True)),
|
||||||
|
('color', models.CharField(default='#667eea', help_text='Hex color code', max_length=7)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('sla_hours', models.PositiveIntegerField(default=24, help_text='SLA response time in hours')),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Ticket Priorities',
|
||||||
|
'ordering': ['level'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TicketStatus',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=50, unique=True)),
|
||||||
|
('color', models.CharField(default='#667eea', help_text='Hex color code', max_length=7)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('is_closed', models.BooleanField(default=False, help_text='Whether this status represents a closed ticket')),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('display_order', models.PositiveIntegerField(default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Ticket Statuses',
|
||||||
|
'ordering': ['display_order', 'name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SupportTicket',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ticket_number', models.CharField(editable=False, max_length=20, unique=True)),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('ticket_type', models.CharField(choices=[('technical', 'Technical Issue'), ('billing', 'Billing Question'), ('feature_request', 'Feature Request'), ('bug_report', 'Bug Report'), ('general', 'General Inquiry'), ('account', 'Account Issue')], default='general', max_length=20)),
|
||||||
|
('user_name', models.CharField(max_length=100)),
|
||||||
|
('user_email', models.EmailField(max_length=254)),
|
||||||
|
('user_phone', models.CharField(blank=True, max_length=20)),
|
||||||
|
('company', models.CharField(blank=True, max_length=100)),
|
||||||
|
('assigned_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('closed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('last_activity', models.DateTimeField(auto_now=True)),
|
||||||
|
('first_response_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('sla_deadline', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('tags', models.CharField(blank=True, help_text='Comma-separated tags', max_length=500)),
|
||||||
|
('internal_notes', models.TextField(blank=True, help_text='Internal notes visible only to staff')),
|
||||||
|
('is_escalated', models.BooleanField(default=False)),
|
||||||
|
('escalation_reason', models.TextField(blank=True)),
|
||||||
|
('attachments', models.JSONField(blank=True, default=list, help_text='List of file paths')),
|
||||||
|
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='support.ticketcategory')),
|
||||||
|
('priority', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='support.ticketpriority')),
|
||||||
|
('status', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='support.ticketstatus')),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='support_tickets', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TicketMessage',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('message_type', models.CharField(choices=[('user_message', 'User Message'), ('agent_response', 'Agent Response'), ('system_note', 'System Note'), ('status_change', 'Status Change'), ('assignment_change', 'Assignment Change'), ('escalation', 'Escalation')], default='user_message', max_length=20)),
|
||||||
|
('content', models.TextField()),
|
||||||
|
('author_name', models.CharField(blank=True, max_length=100)),
|
||||||
|
('author_email', models.EmailField(blank=True, max_length=254)),
|
||||||
|
('is_internal', models.BooleanField(default=False, help_text='Internal message not visible to user')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('attachments', models.JSONField(blank=True, default=list, help_text='List of file paths')),
|
||||||
|
('is_read', models.BooleanField(default=False)),
|
||||||
|
('read_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('read_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='read_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='support.supportticket')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['created_at'],
|
||||||
|
'indexes': [models.Index(fields=['ticket', 'created_at'], name='support_tic_ticket__0cd9bd_idx'), models.Index(fields=['author'], name='support_tic_author__503d4b_idx'), models.Index(fields=['message_type'], name='support_tic_message_6220bd_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TicketActivity',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('activity_type', models.CharField(choices=[('created', 'Ticket Created'), ('updated', 'Ticket Updated'), ('status_changed', 'Status Changed'), ('assigned', 'Ticket Assigned'), ('message_added', 'Message Added'), ('escalated', 'Ticket Escalated'), ('closed', 'Ticket Closed'), ('reopened', 'Ticket Reopened')], max_length=20)),
|
||||||
|
('description', models.TextField()),
|
||||||
|
('user_name', models.CharField(blank=True, max_length=100)),
|
||||||
|
('old_value', models.TextField(blank=True)),
|
||||||
|
('new_value', models.TextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='support.supportticket')),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_activities', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['ticket', 'created_at'], name='support_tic_ticket__4097ca_idx'), models.Index(fields=['activity_type'], name='support_tic_activit_9c98a0_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='supportticket',
|
||||||
|
index=models.Index(fields=['ticket_number'], name='support_sup_ticket__4a7d4b_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='supportticket',
|
||||||
|
index=models.Index(fields=['user_email'], name='support_sup_user_em_c518a8_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='supportticket',
|
||||||
|
index=models.Index(fields=['status'], name='support_sup_status__7b4480_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='supportticket',
|
||||||
|
index=models.Index(fields=['priority'], name='support_sup_priorit_5d48ff_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='supportticket',
|
||||||
|
index=models.Index(fields=['assigned_to'], name='support_sup_assigne_53b075_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='supportticket',
|
||||||
|
index=models.Index(fields=['created_at'], name='support_sup_created_83a137_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2025-10-07 18:06
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('support', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='KnowledgeBaseCategory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('slug', models.SlugField(max_length=120, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('icon', models.CharField(default='fa-book', help_text='FontAwesome icon class', max_length=50)),
|
||||||
|
('color', models.CharField(default='#667eea', help_text='Hex color code', max_length=7)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('display_order', models.PositiveIntegerField(default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Knowledge Base Categories',
|
||||||
|
'ordering': ['display_order', 'name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='KnowledgeBaseArticle',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('slug', models.SlugField(max_length=220, unique=True)),
|
||||||
|
('content', models.TextField()),
|
||||||
|
('summary', models.TextField(blank=True, help_text='Short summary of the article')),
|
||||||
|
('meta_description', models.CharField(blank=True, max_length=160)),
|
||||||
|
('keywords', models.CharField(blank=True, help_text='Comma-separated keywords', max_length=500)),
|
||||||
|
('is_published', models.BooleanField(default=False)),
|
||||||
|
('is_featured', models.BooleanField(default=False)),
|
||||||
|
('view_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('helpful_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('not_helpful_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='kb_articles', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='articles', to='support.knowledgebasecategory')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['slug'], name='support_kno_slug_0c7b3a_idx'), models.Index(fields=['category'], name='support_kno_categor_733ba1_idx'), models.Index(fields=['is_published'], name='support_kno_is_publ_402a55_idx'), models.Index(fields=['created_at'], name='support_kno_created_ef91a5_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
39
gnx-react/backend/support/migrations/0003_registeredemail.py
Normal file
39
gnx-react/backend/support/migrations/0003_registeredemail.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2025-10-07 18:58
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('support', '0002_knowledgebasecategory_knowledgebasearticle'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RegisteredEmail',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('email', models.EmailField(max_length=254, unique=True, validators=[django.core.validators.EmailValidator()])),
|
||||||
|
('company_name', models.CharField(blank=True, help_text='Company or organization name', max_length=200)),
|
||||||
|
('contact_name', models.CharField(blank=True, help_text='Primary contact name', max_length=200)),
|
||||||
|
('notes', models.TextField(blank=True, help_text='Internal notes about this registration')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Whether this email can submit tickets')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('last_ticket_date', models.DateTimeField(blank=True, help_text='Last time this email submitted a ticket', null=True)),
|
||||||
|
('ticket_count', models.PositiveIntegerField(default=0, help_text='Total number of tickets submitted')),
|
||||||
|
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registered_emails', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Registered Email',
|
||||||
|
'verbose_name_plural': 'Registered Emails',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['email'], name='support_reg_email_ee78ad_idx'), models.Index(fields=['is_active'], name='support_reg_is_acti_fadc86_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
gnx-react/backend/support/migrations/__init__.py
Normal file
0
gnx-react/backend/support/migrations/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
320
gnx-react/backend/support/models.py
Normal file
320
gnx-react/backend/support/models.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.validators import EmailValidator
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
class TicketStatus(models.Model):
|
||||||
|
name = models.CharField(max_length=50, unique=True)
|
||||||
|
color = models.CharField(max_length=7, default='#667eea', help_text='Hex color code')
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
is_closed = models.BooleanField(default=False, help_text='Whether this status represents a closed ticket')
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
display_order = models.PositiveIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['display_order', 'name']
|
||||||
|
verbose_name_plural = 'Ticket Statuses'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPriority(models.Model):
|
||||||
|
name = models.CharField(max_length=50, unique=True)
|
||||||
|
level = models.PositiveIntegerField(unique=True, help_text='Lower number = higher priority')
|
||||||
|
color = models.CharField(max_length=7, default='#667eea', help_text='Hex color code')
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
sla_hours = models.PositiveIntegerField(default=24, help_text='SLA response time in hours')
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['level']
|
||||||
|
verbose_name_plural = 'Ticket Priorities'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCategory(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
color = models.CharField(max_length=7, default='#667eea', help_text='Hex color code')
|
||||||
|
icon = models.CharField(max_length=50, default='fa-question-circle', help_text='FontAwesome icon class')
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
display_order = models.PositiveIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['display_order', 'name']
|
||||||
|
verbose_name_plural = 'Ticket Categories'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class SupportTicket(models.Model):
|
||||||
|
TICKET_TYPES = [
|
||||||
|
('technical', 'Technical Issue'),
|
||||||
|
('billing', 'Billing Question'),
|
||||||
|
('feature_request', 'Feature Request'),
|
||||||
|
('bug_report', 'Bug Report'),
|
||||||
|
('general', 'General Inquiry'),
|
||||||
|
('account', 'Account Issue'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ticket_number = models.CharField(max_length=20, unique=True, editable=False)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField()
|
||||||
|
ticket_type = models.CharField(max_length=20, choices=TICKET_TYPES, default='general')
|
||||||
|
|
||||||
|
# User information
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='support_tickets', null=True, blank=True)
|
||||||
|
user_name = models.CharField(max_length=100)
|
||||||
|
user_email = models.EmailField()
|
||||||
|
user_phone = models.CharField(max_length=20, blank=True)
|
||||||
|
company = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
# Ticket management
|
||||||
|
category = models.ForeignKey(TicketCategory, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
priority = models.ForeignKey(TicketPriority, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
status = models.ForeignKey(TicketStatus, on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
|
assigned_to = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_tickets')
|
||||||
|
assigned_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
closed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_activity = models.DateTimeField(auto_now=True)
|
||||||
|
first_response_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
sla_deadline = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Additional fields
|
||||||
|
tags = models.CharField(max_length=500, blank=True, help_text='Comma-separated tags')
|
||||||
|
internal_notes = models.TextField(blank=True, help_text='Internal notes visible only to staff')
|
||||||
|
is_escalated = models.BooleanField(default=False)
|
||||||
|
escalation_reason = models.TextField(blank=True)
|
||||||
|
attachments = models.JSONField(default=list, blank=True, help_text='List of file paths')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['ticket_number']),
|
||||||
|
models.Index(fields=['user_email']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['priority']),
|
||||||
|
models.Index(fields=['assigned_to']),
|
||||||
|
models.Index(fields=['created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.ticket_number} - {self.title}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.ticket_number:
|
||||||
|
self.ticket_number = self.generate_ticket_number()
|
||||||
|
|
||||||
|
# Set SLA deadline based on priority
|
||||||
|
if not self.sla_deadline and self.priority:
|
||||||
|
self.sla_deadline = timezone.now() + timezone.timedelta(hours=self.priority.sla_hours)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_ticket_number():
|
||||||
|
"""Generate a unique ticket number in format: TKT-YYYYMMDD-XXXXX"""
|
||||||
|
today = timezone.now().strftime('%Y%m%d')
|
||||||
|
random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5))
|
||||||
|
ticket_number = f'TKT-{today}-{random_str}'
|
||||||
|
|
||||||
|
# Ensure uniqueness
|
||||||
|
while SupportTicket.objects.filter(ticket_number=ticket_number).exists():
|
||||||
|
random_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=5))
|
||||||
|
ticket_number = f'TKT-{today}-{random_str}'
|
||||||
|
|
||||||
|
return ticket_number
|
||||||
|
|
||||||
|
|
||||||
|
class TicketMessage(models.Model):
|
||||||
|
MESSAGE_TYPES = [
|
||||||
|
('user_message', 'User Message'),
|
||||||
|
('agent_response', 'Agent Response'),
|
||||||
|
('system_note', 'System Note'),
|
||||||
|
('status_change', 'Status Change'),
|
||||||
|
('assignment_change', 'Assignment Change'),
|
||||||
|
('escalation', 'Escalation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ticket = models.ForeignKey(SupportTicket, on_delete=models.CASCADE, related_name='messages')
|
||||||
|
message_type = models.CharField(max_length=20, choices=MESSAGE_TYPES, default='user_message')
|
||||||
|
content = models.TextField()
|
||||||
|
|
||||||
|
# Author information
|
||||||
|
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='ticket_messages')
|
||||||
|
author_name = models.CharField(max_length=100, blank=True)
|
||||||
|
author_email = models.EmailField(blank=True)
|
||||||
|
|
||||||
|
# Message metadata
|
||||||
|
is_internal = models.BooleanField(default=False, help_text='Internal message not visible to user')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
attachments = models.JSONField(default=list, blank=True, help_text='List of file paths')
|
||||||
|
|
||||||
|
# Read status
|
||||||
|
is_read = models.BooleanField(default=False)
|
||||||
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
read_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='read_messages')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['ticket', 'created_at']),
|
||||||
|
models.Index(fields=['author']),
|
||||||
|
models.Index(fields=['message_type']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Message on {self.ticket.ticket_number} at {self.created_at}"
|
||||||
|
|
||||||
|
|
||||||
|
class TicketActivity(models.Model):
|
||||||
|
ACTIVITY_TYPES = [
|
||||||
|
('created', 'Ticket Created'),
|
||||||
|
('updated', 'Ticket Updated'),
|
||||||
|
('status_changed', 'Status Changed'),
|
||||||
|
('assigned', 'Ticket Assigned'),
|
||||||
|
('message_added', 'Message Added'),
|
||||||
|
('escalated', 'Ticket Escalated'),
|
||||||
|
('closed', 'Ticket Closed'),
|
||||||
|
('reopened', 'Ticket Reopened'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ticket = models.ForeignKey(SupportTicket, on_delete=models.CASCADE, related_name='activities')
|
||||||
|
activity_type = models.CharField(max_length=20, choices=ACTIVITY_TYPES)
|
||||||
|
description = models.TextField()
|
||||||
|
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='ticket_activities')
|
||||||
|
user_name = models.CharField(max_length=100, blank=True)
|
||||||
|
old_value = models.TextField(blank=True)
|
||||||
|
new_value = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['ticket', 'created_at']),
|
||||||
|
models.Index(fields=['activity_type']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.activity_type} - {self.ticket.ticket_number}"
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseCategory(models.Model):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(max_length=120, unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
icon = models.CharField(max_length=50, default='fa-book', help_text='FontAwesome icon class')
|
||||||
|
color = models.CharField(max_length=7, default='#667eea', help_text='Hex color code')
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
display_order = models.PositiveIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['display_order', 'name']
|
||||||
|
verbose_name_plural = 'Knowledge Base Categories'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseArticle(models.Model):
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
slug = models.SlugField(max_length=220, unique=True)
|
||||||
|
category = models.ForeignKey(KnowledgeBaseCategory, on_delete=models.SET_NULL, null=True, related_name='articles')
|
||||||
|
content = models.TextField()
|
||||||
|
summary = models.TextField(blank=True, help_text='Short summary of the article')
|
||||||
|
|
||||||
|
# SEO and metadata
|
||||||
|
meta_description = models.CharField(max_length=160, blank=True)
|
||||||
|
keywords = models.CharField(max_length=500, blank=True, help_text='Comma-separated keywords')
|
||||||
|
|
||||||
|
# Article management
|
||||||
|
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='kb_articles')
|
||||||
|
is_published = models.BooleanField(default=False)
|
||||||
|
is_featured = models.BooleanField(default=False)
|
||||||
|
view_count = models.PositiveIntegerField(default=0)
|
||||||
|
helpful_count = models.PositiveIntegerField(default=0)
|
||||||
|
not_helpful_count = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
published_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['slug']),
|
||||||
|
models.Index(fields=['category']),
|
||||||
|
models.Index(fields=['is_published']),
|
||||||
|
models.Index(fields=['created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class SupportSettings(models.Model):
|
||||||
|
setting_name = models.CharField(max_length=100, unique=True)
|
||||||
|
setting_value = models.TextField()
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = 'Support Settings'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.setting_name
|
||||||
|
|
||||||
|
|
||||||
|
class RegisteredEmail(models.Model):
|
||||||
|
"""
|
||||||
|
Email addresses that are authorized to submit support tickets
|
||||||
|
Only admins can add/remove emails from this list
|
||||||
|
"""
|
||||||
|
email = models.EmailField(unique=True, validators=[EmailValidator()])
|
||||||
|
company_name = models.CharField(max_length=200, blank=True, help_text='Company or organization name')
|
||||||
|
contact_name = models.CharField(max_length=200, blank=True, help_text='Primary contact name')
|
||||||
|
notes = models.TextField(blank=True, help_text='Internal notes about this registration')
|
||||||
|
is_active = models.BooleanField(default=True, help_text='Whether this email can submit tickets')
|
||||||
|
added_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='registered_emails')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
last_ticket_date = models.DateTimeField(null=True, blank=True, help_text='Last time this email submitted a ticket')
|
||||||
|
ticket_count = models.PositiveIntegerField(default=0, help_text='Total number of tickets submitted')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Registered Email'
|
||||||
|
verbose_name_plural = 'Registered Emails'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['email']),
|
||||||
|
models.Index(fields=['is_active']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.email} ({self.company_name or 'No company'})"
|
||||||
|
|
||||||
|
def increment_ticket_count(self):
|
||||||
|
"""Increment ticket count and update last ticket date"""
|
||||||
|
self.ticket_count += 1
|
||||||
|
self.last_ticket_date = timezone.now()
|
||||||
|
self.save(update_fields=['ticket_count', 'last_ticket_date'])
|
||||||
204
gnx-react/backend/support/serializers.py
Normal file
204
gnx-react/backend/support/serializers.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import (
|
||||||
|
SupportTicket, TicketStatus, TicketPriority, TicketCategory,
|
||||||
|
TicketMessage, TicketActivity, KnowledgeBaseCategory,
|
||||||
|
KnowledgeBaseArticle, SupportSettings
|
||||||
|
)
|
||||||
|
from .email_service import SupportEmailService
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStatusSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = TicketStatus
|
||||||
|
fields = ['id', 'name', 'color', 'description', 'is_closed', 'display_order']
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPrioritySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = TicketPriority
|
||||||
|
fields = ['id', 'name', 'level', 'color', 'description', 'sla_hours']
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCategorySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = TicketCategory
|
||||||
|
fields = ['id', 'name', 'description', 'color', 'icon', 'display_order']
|
||||||
|
|
||||||
|
|
||||||
|
class TicketMessageSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = TicketMessage
|
||||||
|
fields = [
|
||||||
|
'id', 'ticket', 'message_type', 'content', 'author_name',
|
||||||
|
'author_email', 'is_internal', 'created_at', 'updated_at',
|
||||||
|
'attachments', 'is_read'
|
||||||
|
]
|
||||||
|
read_only_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class TicketActivitySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = TicketActivity
|
||||||
|
fields = [
|
||||||
|
'id', 'activity_type', 'description', 'user_name',
|
||||||
|
'old_value', 'new_value', 'created_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class SupportTicketSerializer(serializers.ModelSerializer):
|
||||||
|
status_name = serializers.CharField(source='status.name', read_only=True)
|
||||||
|
status_color = serializers.CharField(source='status.color', read_only=True)
|
||||||
|
priority_name = serializers.CharField(source='priority.name', read_only=True)
|
||||||
|
priority_color = serializers.CharField(source='priority.color', read_only=True)
|
||||||
|
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||||
|
messages = TicketMessageSerializer(many=True, read_only=True)
|
||||||
|
activities = TicketActivitySerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SupportTicket
|
||||||
|
fields = [
|
||||||
|
'id', 'ticket_number', 'title', 'description', 'ticket_type',
|
||||||
|
'user_name', 'user_email', 'user_phone', 'company',
|
||||||
|
'category', 'category_name', 'priority', 'priority_name', 'priority_color',
|
||||||
|
'status', 'status_name', 'status_color', 'assigned_to', 'assigned_at',
|
||||||
|
'created_at', 'updated_at', 'closed_at', 'last_activity',
|
||||||
|
'first_response_at', 'sla_deadline', 'tags', 'is_escalated',
|
||||||
|
'escalation_reason', 'attachments', 'messages', 'activities'
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'ticket_number', 'created_at', 'updated_at', 'last_activity',
|
||||||
|
'assigned_at', 'closed_at', 'first_response_at', 'sla_deadline'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SupportTicketCreateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Simplified serializer for creating tickets from public form"""
|
||||||
|
ticket_number = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SupportTicket
|
||||||
|
fields = [
|
||||||
|
'ticket_number', 'title', 'description', 'ticket_type', 'user_name',
|
||||||
|
'user_email', 'user_phone', 'company', 'category'
|
||||||
|
]
|
||||||
|
read_only_fields = ['ticket_number']
|
||||||
|
|
||||||
|
def validate_user_email(self, value):
|
||||||
|
"""
|
||||||
|
Validate that the email is registered and active in the RegisteredEmail model
|
||||||
|
"""
|
||||||
|
from .models import RegisteredEmail
|
||||||
|
|
||||||
|
# Check if email exists and is active in RegisteredEmail model
|
||||||
|
try:
|
||||||
|
registered_email = RegisteredEmail.objects.get(email=value)
|
||||||
|
if not registered_email.is_active:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"This email has been deactivated. "
|
||||||
|
"Please contact us at support@gnxsoft.com for assistance."
|
||||||
|
)
|
||||||
|
except RegisteredEmail.DoesNotExist:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"This email is not registered in our system. "
|
||||||
|
"Please contact us at support@gnxsoft.com to register your email first."
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
from .models import RegisteredEmail
|
||||||
|
|
||||||
|
# Set default status and priority if not set
|
||||||
|
if not validated_data.get('status'):
|
||||||
|
default_status = TicketStatus.objects.filter(name='Open').first()
|
||||||
|
if default_status:
|
||||||
|
validated_data['status'] = default_status
|
||||||
|
|
||||||
|
if not validated_data.get('priority'):
|
||||||
|
default_priority = TicketPriority.objects.filter(name='Medium').first()
|
||||||
|
if default_priority:
|
||||||
|
validated_data['priority'] = default_priority
|
||||||
|
|
||||||
|
ticket = SupportTicket.objects.create(**validated_data)
|
||||||
|
|
||||||
|
# Create initial activity
|
||||||
|
TicketActivity.objects.create(
|
||||||
|
ticket=ticket,
|
||||||
|
activity_type='created',
|
||||||
|
description=f'Ticket created by {ticket.user_name}',
|
||||||
|
user_name=ticket.user_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update registered email statistics
|
||||||
|
try:
|
||||||
|
registered_email = RegisteredEmail.objects.get(email=validated_data['user_email'])
|
||||||
|
registered_email.increment_ticket_count()
|
||||||
|
except RegisteredEmail.DoesNotExist:
|
||||||
|
logger.warning(f'RegisteredEmail not found for {validated_data["user_email"]} after validation')
|
||||||
|
|
||||||
|
# Send email notifications
|
||||||
|
try:
|
||||||
|
email_results = SupportEmailService.send_ticket_created_emails(ticket)
|
||||||
|
logger.info(f'Email notifications sent for ticket {ticket.ticket_number}: {email_results}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send email notifications for ticket {ticket.ticket_number}: {str(e)}')
|
||||||
|
# Don't fail ticket creation if emails fail
|
||||||
|
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStatusCheckSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for checking ticket status by ticket number"""
|
||||||
|
ticket_number = serializers.CharField(max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseCategorySerializer(serializers.ModelSerializer):
|
||||||
|
article_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = KnowledgeBaseCategory
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'slug', 'description', 'icon', 'color',
|
||||||
|
'display_order', 'article_count'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_article_count(self, obj):
|
||||||
|
return obj.articles.filter(is_published=True).count()
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseArticleListSerializer(serializers.ModelSerializer):
|
||||||
|
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||||
|
category_slug = serializers.CharField(source='category.slug', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = KnowledgeBaseArticle
|
||||||
|
fields = [
|
||||||
|
'id', 'title', 'slug', 'category', 'category_name', 'category_slug',
|
||||||
|
'summary', 'is_featured', 'view_count', 'helpful_count',
|
||||||
|
'not_helpful_count', 'created_at', 'updated_at', 'published_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseArticleDetailSerializer(serializers.ModelSerializer):
|
||||||
|
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||||
|
category_slug = serializers.CharField(source='category.slug', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = KnowledgeBaseArticle
|
||||||
|
fields = [
|
||||||
|
'id', 'title', 'slug', 'category', 'category_name', 'category_slug',
|
||||||
|
'content', 'summary', 'meta_description', 'keywords',
|
||||||
|
'is_featured', 'view_count', 'helpful_count', 'not_helpful_count',
|
||||||
|
'created_at', 'updated_at', 'published_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SupportSettingsSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SupportSettings
|
||||||
|
fields = ['id', 'setting_name', 'setting_value', 'description', 'is_active']
|
||||||
|
|
||||||
235
gnx-react/backend/support/signals.py
Normal file
235
gnx-react/backend/support/signals.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Django signals for Support app
|
||||||
|
Handles automatic notifications when tickets are updated
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db.models.signals import post_save, pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .models import SupportTicket, TicketMessage, TicketActivity
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketUpdateNotifier:
|
||||||
|
"""Service for sending ticket update notifications"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_status_change_notification(ticket, old_status, new_status):
|
||||||
|
"""Send email when ticket status changes"""
|
||||||
|
try:
|
||||||
|
subject = f'Ticket Status Updated: {ticket.ticket_number}'
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'ticket': ticket,
|
||||||
|
'ticket_number': ticket.ticket_number,
|
||||||
|
'user_name': ticket.user_name,
|
||||||
|
'title': ticket.title,
|
||||||
|
'old_status': old_status,
|
||||||
|
'new_status': new_status,
|
||||||
|
'status_color': ticket.status.color if ticket.status else '#3b82f6',
|
||||||
|
'updated_at': ticket.updated_at.strftime('%B %d, %Y at %I:%M %p'),
|
||||||
|
'support_url': f'{settings.SITE_URL}/support-center',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render HTML email
|
||||||
|
html_message = render_to_string(
|
||||||
|
'support/ticket_status_update.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create plain text version
|
||||||
|
text_message = render_to_string(
|
||||||
|
'support/ticket_status_update.txt',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[ticket.user_email],
|
||||||
|
)
|
||||||
|
|
||||||
|
email.attach_alternative(html_message, "text/html")
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
|
||||||
|
logger.info(f'Status change notification sent for ticket {ticket.ticket_number}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send status change notification: {str(e)}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_message_notification(ticket, message):
|
||||||
|
"""Send email when a new message is added to the ticket"""
|
||||||
|
try:
|
||||||
|
# Only send if it's an agent response (not user message)
|
||||||
|
if message.message_type != 'agent_response':
|
||||||
|
return False
|
||||||
|
|
||||||
|
subject = f'New Response on Ticket: {ticket.ticket_number}'
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'ticket': ticket,
|
||||||
|
'ticket_number': ticket.ticket_number,
|
||||||
|
'user_name': ticket.user_name,
|
||||||
|
'title': ticket.title,
|
||||||
|
'message': message.content,
|
||||||
|
'message_author': message.author_name or 'Support Team',
|
||||||
|
'created_at': message.created_at.strftime('%B %d, %Y at %I:%M %p'),
|
||||||
|
'support_url': f'{settings.SITE_URL}/support-center',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render HTML email
|
||||||
|
html_message = render_to_string(
|
||||||
|
'support/ticket_message_notification.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create plain text version
|
||||||
|
text_message = render_to_string(
|
||||||
|
'support/ticket_message_notification.txt',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[ticket.user_email],
|
||||||
|
)
|
||||||
|
|
||||||
|
email.attach_alternative(html_message, "text/html")
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
|
||||||
|
logger.info(f'Message notification sent for ticket {ticket.ticket_number}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send message notification: {str(e)}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_assignment_notification(ticket, assigned_to):
|
||||||
|
"""Send email when ticket is assigned"""
|
||||||
|
try:
|
||||||
|
subject = f'Ticket Assigned: {ticket.ticket_number}'
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'ticket': ticket,
|
||||||
|
'ticket_number': ticket.ticket_number,
|
||||||
|
'user_name': ticket.user_name,
|
||||||
|
'title': ticket.title,
|
||||||
|
'assigned_to': assigned_to.get_full_name() or assigned_to.username,
|
||||||
|
'updated_at': ticket.updated_at.strftime('%B %d, %Y at %I:%M %p'),
|
||||||
|
'support_url': f'{settings.SITE_URL}/support-center',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Render HTML email
|
||||||
|
html_message = render_to_string(
|
||||||
|
'support/ticket_assigned_notification.html',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create plain text version
|
||||||
|
text_message = render_to_string(
|
||||||
|
'support/ticket_assigned_notification.txt',
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create email
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[ticket.user_email],
|
||||||
|
)
|
||||||
|
|
||||||
|
email.attach_alternative(html_message, "text/html")
|
||||||
|
email.send(fail_silently=False)
|
||||||
|
|
||||||
|
logger.info(f'Assignment notification sent for ticket {ticket.ticket_number}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Failed to send assignment notification: {str(e)}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Store original values before save
|
||||||
|
@receiver(pre_save, sender=SupportTicket)
|
||||||
|
def store_ticket_original_values(sender, instance, **kwargs):
|
||||||
|
"""Store original values before ticket is updated"""
|
||||||
|
if instance.pk:
|
||||||
|
try:
|
||||||
|
original = SupportTicket.objects.get(pk=instance.pk)
|
||||||
|
instance._original_status = original.status
|
||||||
|
instance._original_assigned_to = original.assigned_to
|
||||||
|
except SupportTicket.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Send notifications after ticket is saved
|
||||||
|
@receiver(post_save, sender=SupportTicket)
|
||||||
|
def send_ticket_update_notifications(sender, instance, created, **kwargs):
|
||||||
|
"""Send notifications when ticket is updated"""
|
||||||
|
if created:
|
||||||
|
# Ticket creation is handled in serializer
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for status change
|
||||||
|
if hasattr(instance, '_original_status') and instance._original_status != instance.status:
|
||||||
|
old_status = instance._original_status.name if instance._original_status else 'Unknown'
|
||||||
|
new_status = instance.status.name if instance.status else 'Unknown'
|
||||||
|
|
||||||
|
TicketUpdateNotifier.send_status_change_notification(
|
||||||
|
instance,
|
||||||
|
old_status,
|
||||||
|
new_status
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create activity log
|
||||||
|
TicketActivity.objects.create(
|
||||||
|
ticket=instance,
|
||||||
|
activity_type='status_changed',
|
||||||
|
description=f'Status changed from {old_status} to {new_status}',
|
||||||
|
old_value=old_status,
|
||||||
|
new_value=new_status
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for assignment change
|
||||||
|
if hasattr(instance, '_original_assigned_to') and instance._original_assigned_to != instance.assigned_to:
|
||||||
|
if instance.assigned_to:
|
||||||
|
TicketUpdateNotifier.send_assignment_notification(
|
||||||
|
instance,
|
||||||
|
instance.assigned_to
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create activity log
|
||||||
|
TicketActivity.objects.create(
|
||||||
|
ticket=instance,
|
||||||
|
activity_type='assigned',
|
||||||
|
description=f'Ticket assigned to {instance.assigned_to.get_full_name() or instance.assigned_to.username}',
|
||||||
|
new_value=str(instance.assigned_to)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Send notification when message is added
|
||||||
|
@receiver(post_save, sender=TicketMessage)
|
||||||
|
def send_message_notification(sender, instance, created, **kwargs):
|
||||||
|
"""Send notification when a new message is added"""
|
||||||
|
if created and not instance.is_internal:
|
||||||
|
# Only send if it's a new message that's not internal
|
||||||
|
TicketUpdateNotifier.send_message_notification(
|
||||||
|
instance.ticket,
|
||||||
|
instance
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Ticket Assigned</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.email-header {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
.ticket-info {
|
||||||
|
background: linear-gradient(135deg, rgba(218, 165, 32, 0.1) 0%, rgba(218, 165, 32, 0.05) 100%);
|
||||||
|
border-left: 4px solid #daa520;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 25px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.assignment-box {
|
||||||
|
background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(139, 92, 246, 0.05) 100%);
|
||||||
|
border-left: 4px solid #8b5cf6;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 25px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #daa520, #d4af37);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 25px 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>👤 Ticket Assigned</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<p>Dear {{ user_name }},</p>
|
||||||
|
|
||||||
|
<p>Your support ticket has been assigned to a team member who will be assisting you.</p>
|
||||||
|
|
||||||
|
<div class="ticket-info">
|
||||||
|
<strong>Ticket:</strong> {{ ticket_number }}<br>
|
||||||
|
<strong>Subject:</strong> {{ title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="assignment-box">
|
||||||
|
<h3 style="margin-top: 0;">Assigned To</h3>
|
||||||
|
<div style="font-size: 18px; font-weight: 700; color: #8b5cf6;">{{ assigned_to }}</div>
|
||||||
|
<p style="margin-top: 10px; color: #666; font-size: 14px;">{{ updated_at }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ support_url }}" class="cta-button">View Ticket Details</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #666;">
|
||||||
|
Your assigned support agent will review your ticket and respond as soon as possible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p style="margin: 0 0 10px 0;">
|
||||||
|
<strong>GNX Software Solutions</strong><br>
|
||||||
|
Enterprise Support Center
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px;">
|
||||||
|
This is an automated notification. For assistance, visit our <a href="{{ support_url }}" style="color: #daa520;">Support Center</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
TICKET ASSIGNED
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
Dear {{ user_name }},
|
||||||
|
|
||||||
|
Your support ticket has been assigned to a team member who will be assisting you.
|
||||||
|
|
||||||
|
Ticket: {{ ticket_number }}
|
||||||
|
Subject: {{ title }}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
ASSIGNED TO
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
{{ assigned_to }}
|
||||||
|
|
||||||
|
{{ updated_at }}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
Your assigned support agent will review your ticket and respond as soon as possible.
|
||||||
|
|
||||||
|
View ticket details:
|
||||||
|
{{ support_url }}
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
GNX Software Solutions - Enterprise Support Center
|
||||||
|
|
||||||
|
This is an automated notification.
|
||||||
|
For assistance, visit our Support Center at {{ support_url }}
|
||||||
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Support Ticket Confirmation</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.email-header {
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
.ticket-number-box {
|
||||||
|
background: linear-gradient(135deg, rgba(218, 165, 32, 0.1) 0%, rgba(218, 165, 32, 0.05) 100%);
|
||||||
|
border-left: 4px solid #daa520;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 25px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.ticket-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #daa520;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.info-section {
|
||||||
|
margin: 25px 0;
|
||||||
|
}
|
||||||
|
.info-row {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #e5e5e5;
|
||||||
|
}
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555555;
|
||||||
|
display: inline-block;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #daa520, #d4af37);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 25px 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>✓ Support Ticket Created</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="email-body">
|
||||||
|
<p>Dear {{ user_name }},</p>
|
||||||
|
|
||||||
|
<p>Thank you for contacting our support team. We've received your support request and our team will respond as soon as possible.</p>
|
||||||
|
|
||||||
|
<!-- Ticket Number Box -->
|
||||||
|
<div class="ticket-number-box">
|
||||||
|
<div style="font-size: 14px; color: #666; margin-bottom: 8px;">Your Ticket Number</div>
|
||||||
|
<div class="ticket-number">{{ ticket_number }}</div>
|
||||||
|
<div style="font-size: 13px; color: #666; margin-top: 8px;">Please save this number for future reference</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Details -->
|
||||||
|
<div class="info-section">
|
||||||
|
<h3 style="color: #0f172a; margin-bottom: 15px;">Ticket Details</h3>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Subject:</span>
|
||||||
|
<span class="info-value">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Type:</span>
|
||||||
|
<span class="info-value">{{ ticket_type }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Category:</span>
|
||||||
|
<span class="info-value">{{ category }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Priority:</span>
|
||||||
|
<span class="info-value">{{ priority }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Status:</span>
|
||||||
|
<span class="status-badge">{{ status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Created:</span>
|
||||||
|
<span class="info-value">{{ created_at }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="info-section">
|
||||||
|
<h3 style="color: #0f172a; margin-bottom: 15px;">Description</h3>
|
||||||
|
<div style="background-color: #f8f8f8; padding: 15px; border-radius: 4px; white-space: pre-wrap;">{{ description }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ support_url }}" class="cta-button">Check Ticket Status</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px; color: #666;">
|
||||||
|
<strong>What happens next?</strong><br>
|
||||||
|
Our support team will review your ticket and respond within our standard SLA timeframe. You'll receive an email notification when there's an update.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<p style="margin: 0 0 10px 0;">
|
||||||
|
<strong>GNX Software Solutions</strong><br>
|
||||||
|
Enterprise Support Center
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px;">
|
||||||
|
This is an automated message. Please do not reply directly to this email.<br>
|
||||||
|
For assistance, please visit our <a href="{{ support_url }}" style="color: #daa520;">Support Center</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
SUPPORT TICKET CREATED
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
Dear {{ user_name }},
|
||||||
|
|
||||||
|
Thank you for contacting our support team. We've received your support request and our team will respond as soon as possible.
|
||||||
|
|
||||||
|
YOUR TICKET NUMBER: {{ ticket_number }}
|
||||||
|
Please save this number for future reference.
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
TICKET DETAILS
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
Subject: {{ title }}
|
||||||
|
Type: {{ ticket_type }}
|
||||||
|
Category: {{ category }}
|
||||||
|
Priority: {{ priority }}
|
||||||
|
Status: {{ status }}
|
||||||
|
Created: {{ created_at }}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
DESCRIPTION
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
{{ description }}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
WHAT HAPPENS NEXT?
|
||||||
|
Our support team will review your ticket and respond within our standard SLA timeframe. You'll receive an email notification when there's an update.
|
||||||
|
|
||||||
|
To check your ticket status, visit:
|
||||||
|
{{ support_url }}
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
GNX Software Solutions - Enterprise Support Center
|
||||||
|
|
||||||
|
This is an automated message. Please do not reply directly to this email.
|
||||||
|
For assistance, please visit our Support Center at {{ support_url }}
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>New Response on Your Ticket</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.email-header {
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
.ticket-info {
|
||||||
|
background: linear-gradient(135deg, rgba(218, 165, 32, 0.1) 0%, rgba(218, 165, 32, 0.05) 100%);
|
||||||
|
border-left: 4px solid #daa520;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 25px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.message-box {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 25px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.message-author {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #10b981;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.message-content {
|
||||||
|
color: #333;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #daa520, #d4af37);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 25px 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>💬 New Response on Your Ticket</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<p>Dear {{ user_name }},</p>
|
||||||
|
|
||||||
|
<p>Our support team has responded to your ticket.</p>
|
||||||
|
|
||||||
|
<div class="ticket-info">
|
||||||
|
<strong>Ticket:</strong> {{ ticket_number }}<br>
|
||||||
|
<strong>Subject:</strong> {{ title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="message-box">
|
||||||
|
<div class="message-author">{{ message_author }} replied:</div>
|
||||||
|
<div class="message-content">{{ message }}</div>
|
||||||
|
<div style="margin-top: 15px; color: #666; font-size: 13px;">{{ created_at }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ support_url }}" class="cta-button">View Full Conversation</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #666;">
|
||||||
|
You can view the complete ticket history and reply to this message in the Support Center.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p style="margin: 0 0 10px 0;">
|
||||||
|
<strong>GNX Software Solutions</strong><br>
|
||||||
|
Enterprise Support Center
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px;">
|
||||||
|
This is an automated notification. For assistance, visit our <a href="{{ support_url }}" style="color: #daa520;">Support Center</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
NEW RESPONSE ON YOUR TICKET
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
Dear {{ user_name }},
|
||||||
|
|
||||||
|
Our support team has responded to your ticket.
|
||||||
|
|
||||||
|
Ticket: {{ ticket_number }}
|
||||||
|
Subject: {{ title }}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
{{ message_author }} replied:
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
{{ message }}
|
||||||
|
|
||||||
|
{{ created_at }}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
View the full conversation and reply:
|
||||||
|
{{ support_url }}
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
GNX Software Solutions - Enterprise Support Center
|
||||||
|
|
||||||
|
This is an automated notification.
|
||||||
|
For assistance, visit our Support Center at {{ support_url }}
|
||||||
|
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>New Support Ticket</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.email-header {
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.alert-badge {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
.ticket-number-box {
|
||||||
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%);
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 25px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.ticket-number {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ef4444;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.customer-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 25px 0;
|
||||||
|
}
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
.priority-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.priority-high {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.priority-medium {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.priority-low {
|
||||||
|
background-color: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.priority-critical {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #0f172a, #1e293b);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 25px 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
.description-box {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #daa520;
|
||||||
|
margin: 20px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>🎫 New Support Ticket</h1>
|
||||||
|
<div class="alert-badge">Requires Attention</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="email-body">
|
||||||
|
<p><strong>A new support ticket has been submitted and requires your attention.</strong></p>
|
||||||
|
|
||||||
|
<!-- Ticket Number Box -->
|
||||||
|
<div class="ticket-number-box">
|
||||||
|
<div style="font-size: 14px; color: #666; margin-bottom: 8px;">Ticket Number</div>
|
||||||
|
<div class="ticket-number">{{ ticket_number }}</div>
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<span class="info-label">Created:</span> {{ created_at }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Information -->
|
||||||
|
<div class="customer-info">
|
||||||
|
<h3 style="margin-top: 0; color: #0f172a;">Customer Information</h3>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-label">Name:</div>
|
||||||
|
<div class="info-value">{{ user_name }}</div>
|
||||||
|
|
||||||
|
<div class="info-label">Email:</div>
|
||||||
|
<div class="info-value"><a href="mailto:{{ user_email }}" style="color: #3b82f6;">{{ user_email }}</a></div>
|
||||||
|
|
||||||
|
{% if user_phone %}
|
||||||
|
<div class="info-label">Phone:</div>
|
||||||
|
<div class="info-value"><a href="tel:{{ user_phone }}" style="color: #3b82f6;">{{ user_phone }}</a></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if company %}
|
||||||
|
<div class="info-label">Company:</div>
|
||||||
|
<div class="info-value">{{ company }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ticket Details -->
|
||||||
|
<div style="margin: 25px 0;">
|
||||||
|
<h3 style="color: #0f172a;">Ticket Details</h3>
|
||||||
|
|
||||||
|
<div style="margin: 15px 0;">
|
||||||
|
<div class="info-label" style="display: block; margin-bottom: 8px;">Subject:</div>
|
||||||
|
<div style="font-size: 18px; font-weight: 600; color: #0f172a;">{{ title }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-grid" style="margin-top: 20px;">
|
||||||
|
<div class="info-label">Type:</div>
|
||||||
|
<div class="info-value">{{ ticket_type }}</div>
|
||||||
|
|
||||||
|
<div class="info-label">Category:</div>
|
||||||
|
<div class="info-value">{{ category }}</div>
|
||||||
|
|
||||||
|
<div class="info-label">Priority:</div>
|
||||||
|
<div>
|
||||||
|
{% if priority == 'Critical' %}
|
||||||
|
<span class="priority-badge priority-critical">{{ priority }}</span>
|
||||||
|
{% elif priority == 'High' %}
|
||||||
|
<span class="priority-badge priority-high">{{ priority }}</span>
|
||||||
|
{% elif priority == 'Low' %}
|
||||||
|
<span class="priority-badge priority-low">{{ priority }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="priority-badge priority-medium">{{ priority }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-label">Status:</div>
|
||||||
|
<div class="info-value">{{ status }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<h3 style="color: #0f172a;">Description</h3>
|
||||||
|
<div class="description-box">{{ description }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div style="text-align: center; margin: 40px 0 20px;">
|
||||||
|
<a href="{{ admin_url }}" class="cta-button" style="margin-right: 10px;">View in Admin Panel</a>
|
||||||
|
<a href="mailto:{{ user_email }}" class="cta-button" style="background: linear-gradient(135deg, #daa520, #d4af37);">Reply to Customer</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; border-radius: 4px; margin-top: 30px;">
|
||||||
|
<strong>⚠️ Action Required</strong><br>
|
||||||
|
Please review and respond to this ticket according to the SLA requirements for {{ priority }} priority tickets.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<p style="margin: 0 0 10px 0;">
|
||||||
|
<strong>GNX Software Solutions</strong><br>
|
||||||
|
Internal Support Notification
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px;">
|
||||||
|
This is an automated notification for new support tickets.<br>
|
||||||
|
Manage tickets in the <a href="{{ admin_url }}" style="color: #3b82f6;">Admin Panel</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
NEW SUPPORT TICKET - REQUIRES ATTENTION
|
||||||
|
======================================================================
|
||||||
|
|
||||||
|
A new support ticket has been submitted and requires your attention.
|
||||||
|
|
||||||
|
TICKET NUMBER: {{ ticket_number }}
|
||||||
|
Created: {{ created_at }}
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
CUSTOMER INFORMATION
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Name: {{ user_name }}
|
||||||
|
Email: {{ user_email }}
|
||||||
|
{% if user_phone %}Phone: {{ user_phone }}{% endif %}
|
||||||
|
{% if company %}Company: {{ company }}{% endif %}
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
TICKET DETAILS
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Subject: {{ title }}
|
||||||
|
Type: {{ ticket_type }}
|
||||||
|
Category: {{ category }}
|
||||||
|
Priority: {{ priority }}
|
||||||
|
Status: {{ status }}
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
DESCRIPTION
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
{{ description }}
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
ACTION REQUIRED
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Please review and respond to this ticket according to the SLA requirements for {{ priority }} priority tickets.
|
||||||
|
|
||||||
|
View ticket in admin panel:
|
||||||
|
{{ admin_url }}
|
||||||
|
|
||||||
|
Reply to customer:
|
||||||
|
mailto:{{ user_email }}
|
||||||
|
|
||||||
|
======================================================================
|
||||||
|
GNX Software Solutions - Internal Support Notification
|
||||||
|
|
||||||
|
This is an automated notification for new support tickets.
|
||||||
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Ticket Status Updated</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333333;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.email-header {
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.email-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
.status-change-box {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 25px;
|
||||||
|
margin: 25px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
.ticket-info {
|
||||||
|
background: linear-gradient(135deg, rgba(218, 165, 32, 0.1) 0%, rgba(218, 165, 32, 0.05) 100%);
|
||||||
|
border-left: 4px solid #daa520;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 25px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.cta-button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #daa520, #d4af37);
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 25px 30px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>🔔 Ticket Status Updated</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<p>Dear {{ user_name }},</p>
|
||||||
|
|
||||||
|
<p>Your support ticket status has been updated.</p>
|
||||||
|
|
||||||
|
<div class="ticket-info">
|
||||||
|
<strong>Ticket:</strong> {{ ticket_number }}<br>
|
||||||
|
<strong>Subject:</strong> {{ title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-change-box">
|
||||||
|
<h3 style="margin-top: 0;">Status Change</h3>
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap;">
|
||||||
|
<span class="status-badge" style="background-color: #94a3b8;">{{ old_status }}</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="status-badge" style="background-color: {{ status_color }};">{{ new_status }}</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 15px; color: #666; font-size: 14px;">Updated: {{ updated_at }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ support_url }}" class="cta-button">View Ticket Details</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #666;">
|
||||||
|
You can check the full details of your ticket and any new messages in the Support Center.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p style="margin: 0 0 10px 0;">
|
||||||
|
<strong>GNX Software Solutions</strong><br>
|
||||||
|
Enterprise Support Center
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px;">
|
||||||
|
This is an automated notification. For assistance, visit our <a href="{{ support_url }}" style="color: #daa520;">Support Center</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
TICKET STATUS UPDATED
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
Dear {{ user_name }},
|
||||||
|
|
||||||
|
Your support ticket status has been updated.
|
||||||
|
|
||||||
|
Ticket: {{ ticket_number }}
|
||||||
|
Subject: {{ title }}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
STATUS CHANGE
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
{{ old_status }} → {{ new_status }}
|
||||||
|
|
||||||
|
Updated: {{ updated_at }}
|
||||||
|
|
||||||
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
You can check the full details of your ticket and any new messages in the Support Center:
|
||||||
|
{{ support_url }}
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
GNX Software Solutions - Enterprise Support Center
|
||||||
|
|
||||||
|
This is an automated notification.
|
||||||
|
For assistance, visit our Support Center at {{ support_url }}
|
||||||
|
|
||||||
3
gnx-react/backend/support/tests.py
Normal file
3
gnx-react/backend/support/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
17
gnx-react/backend/support/urls.py
Normal file
17
gnx-react/backend/support/urls.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'tickets', views.SupportTicketViewSet, basename='ticket')
|
||||||
|
router.register(r'categories', views.TicketCategoryViewSet, basename='category')
|
||||||
|
router.register(r'statuses', views.TicketStatusViewSet, basename='status')
|
||||||
|
router.register(r'priorities', views.TicketPriorityViewSet, basename='priority')
|
||||||
|
router.register(r'knowledge-base-categories', views.KnowledgeBaseCategoryViewSet, basename='kb-category')
|
||||||
|
router.register(r'knowledge-base', views.KnowledgeBaseArticleViewSet, basename='kb-article')
|
||||||
|
router.register(r'settings', views.SupportSettingsViewSet, basename='settings')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
|
|
||||||
216
gnx-react/backend/support/views.py
Normal file
216
gnx-react/backend/support/views.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
from rest_framework import viewsets, status, filters
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
SupportTicket, TicketStatus, TicketPriority, TicketCategory,
|
||||||
|
TicketMessage, KnowledgeBaseCategory, KnowledgeBaseArticle, SupportSettings
|
||||||
|
)
|
||||||
|
from .serializers import (
|
||||||
|
SupportTicketSerializer, SupportTicketCreateSerializer,
|
||||||
|
TicketStatusSerializer, TicketPrioritySerializer, TicketCategorySerializer,
|
||||||
|
TicketMessageSerializer, TicketStatusCheckSerializer,
|
||||||
|
KnowledgeBaseCategorySerializer, KnowledgeBaseArticleListSerializer,
|
||||||
|
KnowledgeBaseArticleDetailSerializer, SupportSettingsSerializer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SupportTicketViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing support tickets.
|
||||||
|
Public endpoint for creating tickets and checking status.
|
||||||
|
"""
|
||||||
|
queryset = SupportTicket.objects.all()
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
search_fields = ['ticket_number', 'title', 'user_email', 'user_name']
|
||||||
|
ordering_fields = ['created_at', 'updated_at', 'priority']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'create':
|
||||||
|
return SupportTicketCreateSerializer
|
||||||
|
return SupportTicketSerializer
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'], url_path='check-status')
|
||||||
|
def check_status(self, request):
|
||||||
|
"""
|
||||||
|
Check the status of a ticket by ticket number.
|
||||||
|
POST /api/support/tickets/check-status/
|
||||||
|
Body: {"ticket_number": "TKT-20231015-XXXXX"}
|
||||||
|
"""
|
||||||
|
serializer = TicketStatusCheckSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
ticket_number = serializer.validated_data['ticket_number']
|
||||||
|
|
||||||
|
try:
|
||||||
|
ticket = SupportTicket.objects.get(ticket_number=ticket_number)
|
||||||
|
ticket_serializer = SupportTicketSerializer(ticket)
|
||||||
|
return Response(ticket_serializer.data)
|
||||||
|
except SupportTicket.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Ticket not found. Please check your ticket number.'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='add-message')
|
||||||
|
def add_message(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Add a message to a ticket.
|
||||||
|
POST /api/support/tickets/{id}/add-message/
|
||||||
|
Body: {
|
||||||
|
"content": "Message content",
|
||||||
|
"author_name": "User Name",
|
||||||
|
"author_email": "user@example.com"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
ticket = self.get_object()
|
||||||
|
|
||||||
|
message_data = {
|
||||||
|
'ticket': ticket.id,
|
||||||
|
'content': request.data.get('content'),
|
||||||
|
'author_name': request.data.get('author_name', ticket.user_name),
|
||||||
|
'author_email': request.data.get('author_email', ticket.user_email),
|
||||||
|
'message_type': 'user_message'
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = TicketMessageSerializer(data=message_data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
# Update ticket's last activity
|
||||||
|
ticket.last_activity = timezone.now()
|
||||||
|
ticket.save()
|
||||||
|
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class TicketCategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for ticket categories.
|
||||||
|
Public read-only access to categories.
|
||||||
|
"""
|
||||||
|
queryset = TicketCategory.objects.filter(is_active=True)
|
||||||
|
serializer_class = TicketCategorySerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class TicketStatusViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for ticket statuses.
|
||||||
|
Public read-only access to statuses.
|
||||||
|
"""
|
||||||
|
queryset = TicketStatus.objects.filter(is_active=True)
|
||||||
|
serializer_class = TicketStatusSerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPriorityViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for ticket priorities.
|
||||||
|
Public read-only access to priorities.
|
||||||
|
"""
|
||||||
|
queryset = TicketPriority.objects.filter(is_active=True)
|
||||||
|
serializer_class = TicketPrioritySerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseCategoryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for knowledge base categories.
|
||||||
|
Public read-only access.
|
||||||
|
"""
|
||||||
|
queryset = KnowledgeBaseCategory.objects.filter(is_active=True)
|
||||||
|
serializer_class = KnowledgeBaseCategorySerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
lookup_field = 'slug'
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBaseArticleViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for knowledge base articles.
|
||||||
|
Public read-only access to published articles.
|
||||||
|
"""
|
||||||
|
queryset = KnowledgeBaseArticle.objects.filter(is_published=True)
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||||
|
search_fields = ['title', 'content', 'summary', 'keywords']
|
||||||
|
ordering_fields = ['created_at', 'view_count', 'helpful_count']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
lookup_field = 'slug'
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
return KnowledgeBaseArticleDetailSerializer
|
||||||
|
return KnowledgeBaseArticleListSerializer
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
# Increment view count
|
||||||
|
instance.view_count += 1
|
||||||
|
instance.save(update_fields=['view_count'])
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='mark-helpful')
|
||||||
|
def mark_helpful(self, request, slug=None):
|
||||||
|
"""
|
||||||
|
Mark an article as helpful.
|
||||||
|
POST /api/support/knowledge-base/{slug}/mark-helpful/
|
||||||
|
Body: {"helpful": true/false}
|
||||||
|
"""
|
||||||
|
article = self.get_object()
|
||||||
|
is_helpful = request.data.get('helpful', True)
|
||||||
|
|
||||||
|
if is_helpful:
|
||||||
|
article.helpful_count += 1
|
||||||
|
else:
|
||||||
|
article.not_helpful_count += 1
|
||||||
|
|
||||||
|
article.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'helpful_count': article.helpful_count,
|
||||||
|
'not_helpful_count': article.not_helpful_count
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='featured')
|
||||||
|
def featured(self, request):
|
||||||
|
"""
|
||||||
|
Get featured articles.
|
||||||
|
GET /api/support/knowledge-base/featured/
|
||||||
|
"""
|
||||||
|
featured_articles = self.queryset.filter(is_featured=True)[:6]
|
||||||
|
serializer = self.get_serializer(featured_articles, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='by-category/(?P<category_slug>[^/.]+)')
|
||||||
|
def by_category(self, request, category_slug=None):
|
||||||
|
"""
|
||||||
|
Get articles by category slug.
|
||||||
|
GET /api/support/knowledge-base/by-category/{category_slug}/
|
||||||
|
"""
|
||||||
|
articles = self.queryset.filter(category__slug=category_slug)
|
||||||
|
page = self.paginate_queryset(articles)
|
||||||
|
if page is not None:
|
||||||
|
serializer = self.get_serializer(page, many=True)
|
||||||
|
return self.get_paginated_response(serializer.data)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(articles, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class SupportSettingsViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for support settings.
|
||||||
|
Public read-only access to active settings.
|
||||||
|
"""
|
||||||
|
queryset = SupportSettings.objects.filter(is_active=True)
|
||||||
|
serializer_class = SupportSettingsSerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
lookup_field = 'setting_name'
|
||||||
973
gnx-react/components/pages/career/JobApplicationForm.tsx
Normal file
973
gnx-react/components/pages/career/JobApplicationForm.tsx
Normal file
@@ -0,0 +1,973 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, FormEvent, ChangeEvent } from "react";
|
||||||
|
import { JobPosition, JobApplication, careerService } from "@/lib/api/careerService";
|
||||||
|
|
||||||
|
interface JobApplicationFormProps {
|
||||||
|
job: JobPosition;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
width: '100%'
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle = {
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#555',
|
||||||
|
marginBottom: '6px',
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '14px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionStyle = {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
padding: 'clamp(16px, 3vw, 20px)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
marginBottom: '16px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.04)'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionHeaderStyle = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '14px',
|
||||||
|
paddingBottom: '10px',
|
||||||
|
borderBottom: '1px solid #f0f0f0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const JobApplicationForm = ({ job, onClose }: JobApplicationFormProps) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
current_position: "",
|
||||||
|
current_company: "",
|
||||||
|
years_of_experience: "",
|
||||||
|
cover_letter: "",
|
||||||
|
portfolio_url: "",
|
||||||
|
linkedin_url: "",
|
||||||
|
github_url: "",
|
||||||
|
website_url: "",
|
||||||
|
available_from: "",
|
||||||
|
notice_period: "",
|
||||||
|
expected_salary: "",
|
||||||
|
salary_currency: "USD",
|
||||||
|
consent: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [resume, setResume] = useState<File | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitStatus, setSubmitStatus] = useState<{
|
||||||
|
type: "success" | "error" | null;
|
||||||
|
message: string;
|
||||||
|
}>({ type: null, message: "" });
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
|
||||||
|
if (type === "checkbox") {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: checked }));
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
const allowedTypes = [
|
||||||
|
"application/pdf",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: "Please upload a PDF, DOC, or DOCX file",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: "Resume file size must be less than 5MB",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResume(file);
|
||||||
|
setSubmitStatus({ type: null, message: "" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitStatus({ type: null, message: "" });
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!resume) {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: "Please upload your resume",
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.consent) {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: "You must consent to data processing to apply",
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const applicationData: JobApplication = {
|
||||||
|
job: job.id,
|
||||||
|
first_name: formData.first_name,
|
||||||
|
last_name: formData.last_name,
|
||||||
|
email: formData.email,
|
||||||
|
phone: formData.phone || undefined,
|
||||||
|
current_position: formData.current_position || undefined,
|
||||||
|
current_company: formData.current_company || undefined,
|
||||||
|
years_of_experience: formData.years_of_experience || undefined,
|
||||||
|
cover_letter: formData.cover_letter || undefined,
|
||||||
|
resume: resume,
|
||||||
|
portfolio_url: formData.portfolio_url || undefined,
|
||||||
|
linkedin_url: formData.linkedin_url || undefined,
|
||||||
|
github_url: formData.github_url || undefined,
|
||||||
|
website_url: formData.website_url || undefined,
|
||||||
|
available_from: formData.available_from || undefined,
|
||||||
|
notice_period: formData.notice_period || undefined,
|
||||||
|
expected_salary: formData.expected_salary ? parseFloat(formData.expected_salary) : undefined,
|
||||||
|
salary_currency: formData.salary_currency || undefined,
|
||||||
|
consent: formData.consent,
|
||||||
|
};
|
||||||
|
|
||||||
|
await careerService.submitApplication(applicationData);
|
||||||
|
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "success",
|
||||||
|
message: "Application submitted successfully! We'll be in touch soon.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setFormData({
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
current_position: "",
|
||||||
|
current_company: "",
|
||||||
|
years_of_experience: "",
|
||||||
|
cover_letter: "",
|
||||||
|
portfolio_url: "",
|
||||||
|
linkedin_url: "",
|
||||||
|
github_url: "",
|
||||||
|
website_url: "",
|
||||||
|
available_from: "",
|
||||||
|
notice_period: "",
|
||||||
|
expected_salary: "",
|
||||||
|
salary_currency: "USD",
|
||||||
|
consent: false,
|
||||||
|
});
|
||||||
|
setResume(null);
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
const fileInput = document.getElementById('resume') as HTMLInputElement;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitStatus({
|
||||||
|
type: "error",
|
||||||
|
message: error instanceof Error ? error.message : "Failed to submit application. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="job-application-form" style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '90vh'
|
||||||
|
}}>
|
||||||
|
{/* Header Section with Gradient */}
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
padding: 'clamp(24px, 4vw, 32px)',
|
||||||
|
borderRadius: '16px 16px 0 0',
|
||||||
|
position: 'relative',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
{/* Close Button */}
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '16px',
|
||||||
|
right: '16px',
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
border: 'none',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '24px',
|
||||||
|
padding: '8px',
|
||||||
|
lineHeight: '1',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(220, 53, 69, 0.9)';
|
||||||
|
e.currentTarget.style.transform = 'scale(1.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.2)';
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 'inherit' }}>close</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="intro" style={{ textAlign: 'center', color: 'white' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '60px',
|
||||||
|
height: '60px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
backdropFilter: 'blur(10px)'
|
||||||
|
}}>
|
||||||
|
<span className="material-symbols-outlined" style={{
|
||||||
|
fontSize: '32px',
|
||||||
|
color: 'white'
|
||||||
|
}}>work_outline</span>
|
||||||
|
</div>
|
||||||
|
<h3 id="application-form-title" className="fw-7" style={{
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 'clamp(20px, 3vw, 24px)',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
Apply for {job.title}
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
fontSize: 'clamp(13px, 2vw, 14px)',
|
||||||
|
margin: '0'
|
||||||
|
}}>
|
||||||
|
Join our team and make an impact
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Content - Scrollable Area */}
|
||||||
|
<form onSubmit={handleSubmit} style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
className="form-scrollable-content"
|
||||||
|
style={{
|
||||||
|
padding: 'clamp(20px, 4vw, 32px)',
|
||||||
|
overflowY: 'scroll',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
touchAction: 'pan-y',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: '#667eea #f0f0f0'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
{submitStatus.type && (
|
||||||
|
<div
|
||||||
|
className={`alert mb-24`}
|
||||||
|
style={{
|
||||||
|
padding: "16px 20px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: submitStatus.type === "success" ? "#d4edda" : "#f8d7da",
|
||||||
|
color: submitStatus.type === "success" ? "#155724" : "#721c24",
|
||||||
|
border: `2px solid ${submitStatus.type === "success" ? "#28a745" : "#dc3545"}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: '20px' }}>
|
||||||
|
{submitStatus.type === "success" ? "check_circle" : "error"}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: '500' }}>{submitStatus.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Personal Information */}
|
||||||
|
<div className="section" style={sectionStyle}>
|
||||||
|
<div style={sectionHeaderStyle}>
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: 'clamp(20px, 3vw, 22px)' }}>person</span>
|
||||||
|
<h4 className="fw-6 mb-0" style={{ color: '#333', fontSize: 'clamp(15px, 2.5vw, 16px)' }}>Personal Information</h4>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12 col-md-6 mb-3">
|
||||||
|
<label htmlFor="first_name" className="form-label" style={labelStyle}>
|
||||||
|
First Name <span style={{ color: '#dc3545' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="first_name"
|
||||||
|
name="first_name"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.first_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-3">
|
||||||
|
<label htmlFor="last_name" className="form-label" style={labelStyle}>
|
||||||
|
Last Name <span style={{ color: '#dc3545' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="last_name"
|
||||||
|
name="last_name"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.last_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-3">
|
||||||
|
<label htmlFor="email" className="form-label" style={labelStyle}>
|
||||||
|
Email Address <span style={{ color: '#dc3545' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-3">
|
||||||
|
<label htmlFor="phone" className="form-label" style={labelStyle}>
|
||||||
|
Phone Number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Professional Information */}
|
||||||
|
<div className="section" style={sectionStyle}>
|
||||||
|
<div style={sectionHeaderStyle}>
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: '22px' }}>work</span>
|
||||||
|
<h4 className="fw-6 mb-0" style={{ color: '#333', fontSize: '16px' }}>Professional Info</h4>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="current_position" className="form-label" style={labelStyle}>
|
||||||
|
Current Position
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="current_position"
|
||||||
|
name="current_position"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.current_position}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="current_company" className="form-label" style={labelStyle}>
|
||||||
|
Current Company
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="current_company"
|
||||||
|
name="current_company"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.current_company}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 mb-24">
|
||||||
|
<label htmlFor="years_of_experience" className="form-label" style={labelStyle}>
|
||||||
|
Years of Experience
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="years_of_experience"
|
||||||
|
name="years_of_experience"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g., 3-5 years"
|
||||||
|
value={formData.years_of_experience}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resume and Cover Letter */}
|
||||||
|
<div className="section" style={sectionStyle}>
|
||||||
|
<div style={sectionHeaderStyle}>
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: '22px' }}>upload_file</span>
|
||||||
|
<h4 className="fw-6 mb-0" style={{ color: '#333', fontSize: '16px' }}>Documents</h4>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12 mb-24">
|
||||||
|
<label htmlFor="resume" className="form-label" style={labelStyle}>
|
||||||
|
Resume (PDF, DOC, DOCX - Max 5MB) <span style={{ color: '#dc3545' }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="resume"
|
||||||
|
name="resume"
|
||||||
|
className="form-control"
|
||||||
|
style={{...inputStyle, padding: '10px 16px'}}
|
||||||
|
accept=".pdf,.doc,.docx"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{resume && <small style={{ color: '#28a745', fontWeight: '500', display: 'block', marginTop: '8px' }}>✓ Selected: {resume.name}</small>}
|
||||||
|
</div>
|
||||||
|
<div className="col-12 mb-3">
|
||||||
|
<label htmlFor="cover_letter" className="form-label" style={labelStyle}>
|
||||||
|
Cover Letter / Message
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="cover_letter"
|
||||||
|
name="cover_letter"
|
||||||
|
className="form-control"
|
||||||
|
style={{...inputStyle, minHeight: '120px', resize: 'vertical'}}
|
||||||
|
rows={5}
|
||||||
|
placeholder="Tell us why you're interested in this position..."
|
||||||
|
value={formData.cover_letter}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className="section" style={sectionStyle}>
|
||||||
|
<div style={sectionHeaderStyle}>
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: '22px' }}>link</span>
|
||||||
|
<h4 className="fw-6 mb-0" style={{ color: '#333', fontSize: '16px' }}>Links (Optional)</h4>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="portfolio_url" className="form-label" style={labelStyle}>
|
||||||
|
Portfolio / Website
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="portfolio_url"
|
||||||
|
name="portfolio_url"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="https://"
|
||||||
|
value={formData.portfolio_url}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="linkedin_url" className="form-label" style={labelStyle}>
|
||||||
|
LinkedIn Profile
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="linkedin_url"
|
||||||
|
name="linkedin_url"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="https://linkedin.com/in/..."
|
||||||
|
value={formData.linkedin_url}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="github_url" className="form-label" style={labelStyle}>
|
||||||
|
GitHub Profile
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="github_url"
|
||||||
|
name="github_url"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="https://github.com/..."
|
||||||
|
value={formData.github_url}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="website_url" className="form-label" style={labelStyle}>
|
||||||
|
Personal Website
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="website_url"
|
||||||
|
name="website_url"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="https://"
|
||||||
|
value={formData.website_url}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Availability & Salary */}
|
||||||
|
<div className="section" style={sectionStyle}>
|
||||||
|
<div style={sectionHeaderStyle}>
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: '22px' }}>event_available</span>
|
||||||
|
<h4 className="fw-6 mb-0" style={{ color: '#333', fontSize: '16px' }}>Availability</h4>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="available_from" className="form-label" style={labelStyle}>
|
||||||
|
Available From
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="available_from"
|
||||||
|
name="available_from"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.available_from}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="notice_period" className="form-label" style={labelStyle}>
|
||||||
|
Notice Period
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="notice_period"
|
||||||
|
name="notice_period"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="e.g., 2 weeks, 1 month"
|
||||||
|
value={formData.notice_period}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="expected_salary" className="form-label" style={labelStyle}>
|
||||||
|
Expected Salary
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="expected_salary"
|
||||||
|
name="expected_salary"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Amount"
|
||||||
|
value={formData.expected_salary}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6 mb-24">
|
||||||
|
<label htmlFor="salary_currency" className="form-label" style={labelStyle}>
|
||||||
|
Currency
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="salary_currency"
|
||||||
|
name="salary_currency"
|
||||||
|
className="form-control"
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.salary_currency}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.borderColor = '#667eea';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(102, 126, 234, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.borderColor = '#e0e0e0';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="GBP">GBP</option>
|
||||||
|
<option value="INR">INR</option>
|
||||||
|
<option value="AUD">AUD</option>
|
||||||
|
<option value="CAD">CAD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consent */}
|
||||||
|
<div className="section" style={{
|
||||||
|
background: 'linear-gradient(135deg, #fff9e6 0%, #fffbf0 100%)',
|
||||||
|
padding: 'clamp(14px, 3vw, 16px)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #ffd700',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<div className="form-check d-flex align-items-start">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="consent"
|
||||||
|
name="consent"
|
||||||
|
className="form-check-input"
|
||||||
|
style={{
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
marginTop: '2px',
|
||||||
|
marginRight: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
accentColor: '#667eea'
|
||||||
|
}}
|
||||||
|
checked={formData.consent}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label htmlFor="consent" className="form-check-label" style={{
|
||||||
|
fontSize: 'clamp(12px, 2vw, 13px)',
|
||||||
|
color: '#555',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}>
|
||||||
|
I consent to data processing for recruitment purposes. <span style={{ color: '#dc3545', fontWeight: '600' }}>*</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit and Cancel Buttons - Fixed Footer */}
|
||||||
|
<div className="text-center" style={{
|
||||||
|
paddingTop: '16px',
|
||||||
|
paddingBottom: '16px',
|
||||||
|
borderTop: '2px solid #e8e8e8',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '16px clamp(20px, 4vw, 32px)',
|
||||||
|
borderRadius: '0 0 16px 16px',
|
||||||
|
boxShadow: '0 -4px 12px rgba(0,0,0,0.05)',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
minWidth: "160px",
|
||||||
|
backgroundColor: isSubmitting ? '#ccc' : 'white',
|
||||||
|
color: '#333',
|
||||||
|
border: '2px solid #667eea',
|
||||||
|
padding: '12px 32px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '600',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
e.currentTarget.style.backgroundColor = '#FFD700';
|
||||||
|
e.currentTarget.style.borderColor = '#FFD700';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0,0,0,0.15)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
e.currentTarget.style.backgroundColor = 'white';
|
||||||
|
e.currentTarget.style.borderColor = '#667eea';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: '20px', animation: 'spin 1s linear infinite' }}>progress_activity</span>
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: '20px' }}>send</span>
|
||||||
|
Submit Application
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onClose && !isSubmitting && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
minWidth: "120px",
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#666',
|
||||||
|
border: '2px solid #e0e0e0',
|
||||||
|
padding: '12px 24px',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: '600',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
||||||
|
e.currentTarget.style.borderColor = '#999';
|
||||||
|
e.currentTarget.style.color = '#333';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||||
|
e.currentTarget.style.color = '#666';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: '18px' }}>close</span>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
marginTop: '10px',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: 'clamp(11px, 2vw, 12px)'
|
||||||
|
}}>
|
||||||
|
By submitting, you agree to our terms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styling */
|
||||||
|
.form-scrollable-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-scrollable-content::-webkit-scrollbar-track {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-scrollable-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #667eea;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-scrollable-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure smooth scrolling on touch devices */
|
||||||
|
.form-scrollable-content {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better mobile input styling */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-control {
|
||||||
|
font-size: 16px !important; /* Prevents zoom on iOS */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JobApplicationForm;
|
||||||
|
|
||||||
@@ -1,152 +1,680 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { JobPosition } from "@/lib/api/careerService";
|
||||||
|
import JobApplicationForm from "./JobApplicationForm";
|
||||||
|
|
||||||
|
interface JobSingleProps {
|
||||||
|
job: JobPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JobSingle = ({ job }: JobSingleProps) => {
|
||||||
|
const [showApplicationForm, setShowApplicationForm] = useState(false);
|
||||||
|
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (showApplicationForm) {
|
||||||
|
// Get scrollbar width to prevent layout shift
|
||||||
|
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||||
|
|
||||||
|
// Save current scroll position
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
|
||||||
|
// Prevent background scroll
|
||||||
|
document.body.style.position = 'fixed';
|
||||||
|
document.body.style.top = `-${scrollY}px`;
|
||||||
|
document.body.style.left = '0';
|
||||||
|
document.body.style.right = '0';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
if (scrollbarWidth > 0) {
|
||||||
|
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get the scroll position from body top
|
||||||
|
const scrollY = parseInt(document.body.style.top || '0') * -1;
|
||||||
|
|
||||||
|
// Restore scroll
|
||||||
|
document.body.style.position = '';
|
||||||
|
document.body.style.top = '';
|
||||||
|
document.body.style.left = '';
|
||||||
|
document.body.style.right = '';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.style.paddingRight = '';
|
||||||
|
|
||||||
|
// Restore scroll position
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
const scrollY = parseInt(document.body.style.top || '0') * -1;
|
||||||
|
document.body.style.position = '';
|
||||||
|
document.body.style.top = '';
|
||||||
|
document.body.style.left = '';
|
||||||
|
document.body.style.right = '';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.style.paddingRight = '';
|
||||||
|
if (scrollY > 0) {
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [showApplicationForm]);
|
||||||
|
|
||||||
|
const formatSalary = () => {
|
||||||
|
if (job.salary_min && job.salary_max) {
|
||||||
|
return `${job.salary_currency} ${job.salary_min}-${job.salary_max} ${job.salary_period}`;
|
||||||
|
} else if (job.salary_min) {
|
||||||
|
return `From ${job.salary_currency} ${job.salary_min} ${job.salary_period}`;
|
||||||
|
} else if (job.salary_max) {
|
||||||
|
return `Up to ${job.salary_currency} ${job.salary_max} ${job.salary_period}`;
|
||||||
|
}
|
||||||
|
return "Competitive";
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToForm = () => {
|
||||||
|
setShowApplicationForm(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
const formElement = document.getElementById('application-form');
|
||||||
|
if (formElement) {
|
||||||
|
formElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
const JobSingle = () => {
|
|
||||||
return (
|
return (
|
||||||
<section className="job-single fix-top pb-120 sticky-wrapper">
|
<>
|
||||||
<div className="container">
|
{/* Job Header Banner */}
|
||||||
<div className="row vertical-column-gap">
|
<section className="job-header pt-80 pt-md-100 pt-lg-120 pb-60 pb-md-70 pb-lg-80" style={{
|
||||||
<div className="col-12 col-lg-7">
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
<div className="j-d-content sticky-item">
|
position: 'relative'
|
||||||
<div className="intro">
|
}}>
|
||||||
<h2 className="mt-8 text-secondary fw-7 title-anim mb-24">
|
<div className="container">
|
||||||
UI/UX Design
|
<div className="row">
|
||||||
</h2>
|
<div className="col-12">
|
||||||
<p>
|
<div className="job-header-content" style={{ color: 'white' }}>
|
||||||
Position: <span className="position mb-12">(02)</span>
|
<div className="mb-12 mb-md-16">
|
||||||
</p>
|
<span className="badge" style={{
|
||||||
<p>
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
Location: <span className="location">(Remote)</span>
|
color: 'white',
|
||||||
</p>
|
padding: '6px 12px',
|
||||||
</div>
|
borderRadius: '20px',
|
||||||
<div className="group pt-120">
|
fontSize: '12px',
|
||||||
<h4 className="mt-8 text-secondary title-anim fw-6 mb-24">
|
fontWeight: '500',
|
||||||
Who we are
|
textTransform: 'uppercase',
|
||||||
</h4>
|
letterSpacing: '1px',
|
||||||
<p className="cur-lg">
|
display: 'inline-block'
|
||||||
Lorem ipsum dolor sit amet consectetur. Augue morbi sapien
|
}}>
|
||||||
malesuada augue massa vivamus pharetra. Pellentesque velit
|
{job.department || 'Career Opportunity'}
|
||||||
lectus dui convallis posuere viverra enim mauris. Pulvinar
|
</span>
|
||||||
quam vitae ut viverra. Vitae quis cursus magna sit amet neque
|
</div>
|
||||||
ultricies lectus massa. Sem mauris tincidunt risus enim
|
<h1 className="fw-7 mb-16 mb-md-20 mb-lg-24" style={{
|
||||||
adipiscing viverra. Interdum lectus interdum diam ultricies
|
fontSize: 'clamp(1.75rem, 5vw, 3.5rem)',
|
||||||
molestie. In et ullamcorper semper odio enim.
|
lineHeight: '1.2',
|
||||||
</p>
|
color: 'white'
|
||||||
</div>
|
}}>
|
||||||
<div className="group mt-60">
|
{job.title}
|
||||||
<h4 className="mt-8 text-secondary title-anim fw-6 mb-24">
|
</h1>
|
||||||
What you want
|
<div className="job-meta d-flex flex-wrap" style={{
|
||||||
</h4>
|
fontSize: 'clamp(13px, 2vw, 16px)',
|
||||||
<ul>
|
gap: 'clamp(12px, 2vw, 16px)'
|
||||||
<li>
|
}}>
|
||||||
you have at least three years of commercial experience
|
<div className="meta-item d-flex align-items-center">
|
||||||
</li>
|
<span className="material-symbols-outlined me-1 me-md-2" style={{ fontSize: 'clamp(18px, 3vw, 24px)' }}>location_on</span>
|
||||||
<li>
|
<span>{job.location}</span>
|
||||||
you have a strong web/UI portfolio including published
|
</div>
|
||||||
projects
|
<div className="meta-item d-flex align-items-center">
|
||||||
</li>
|
<span className="material-symbols-outlined me-1 me-md-2" style={{ fontSize: 'clamp(18px, 3vw, 24px)' }}>work</span>
|
||||||
<li>fluent English in verbal and written communication</li>
|
<span className="d-none d-sm-inline">{job.employment_type.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase())}</span>
|
||||||
<li>
|
<span className="d-sm-none">
|
||||||
you are passionate about user interface and web design
|
{job.employment_type.split('-')[0].charAt(0).toUpperCase() + job.employment_type.split('-')[0].slice(1)}
|
||||||
</li>
|
</span>
|
||||||
<li>issues are challenges not show-stoppers for you</li>
|
</div>
|
||||||
<li>you are a trend seeker</li>
|
<div className="meta-item d-flex align-items-center">
|
||||||
<li>you bring a lot of attention to details</li>
|
<span className="material-symbols-outlined me-1 me-md-2" style={{ fontSize: 'clamp(18px, 3vw, 24px)' }}>group</span>
|
||||||
<li>
|
<span className="d-none d-sm-inline">{job.open_positions} {job.open_positions === 1 ? 'Position' : 'Positions'}</span>
|
||||||
you plan upfront, think ahead, and are ready to be surprised
|
<span className="d-sm-none">{job.open_positions} {job.open_positions === 1 ? 'Pos' : 'Pos'}</span>
|
||||||
</li>
|
</div>
|
||||||
<li>you think about the full picture</li>
|
{job.experience_required && (
|
||||||
<li>
|
<div className="meta-item d-flex align-items-center d-none d-md-flex">
|
||||||
you are familiar with any UI design tool, i.e., Sketch,
|
<span className="material-symbols-outlined me-2">school</span>
|
||||||
Figma or Adobe XD
|
<span>{job.experience_required}</span>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="group mt-60">
|
|
||||||
<h4 className="mt-8 text-secondary title-anim fw-6 mb-24">
|
|
||||||
Who we are
|
|
||||||
</h4>
|
|
||||||
<p className="cur-lg">
|
|
||||||
Lorem ipsum dolor sit amet consectetur. Augue morbi sapien
|
|
||||||
malesuada augue massa vivamus pharetra. Pellentesque velit
|
|
||||||
lectus dui convallis posuere viverra enim mauris. Pulvinar
|
|
||||||
quam vitae ut viverra. Vitae quis cursus magna sit amet neque
|
|
||||||
ultricies lectus massa. Sem mauris tincidunt risus enim
|
|
||||||
adipiscing viverra. Interdum lectus interdum diam ultricies
|
|
||||||
molestie. In et ullamcorper semper odio enim.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="group mt-60">
|
|
||||||
<h4 className="mt-8 text-secondary title-anim fw-6 mb-24">
|
|
||||||
Bonus points
|
|
||||||
</h4>
|
|
||||||
<ul>
|
|
||||||
<li>you have at least three years</li>
|
|
||||||
<li>you have a strong web/UI portfolio including</li>
|
|
||||||
<li>fluent English in verbal</li>
|
|
||||||
<li>you are passionate about user interface</li>
|
|
||||||
<li>issues are challenges</li>
|
|
||||||
<li>you are a seeker</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="group mt-60">
|
|
||||||
<h4 className="mt-8 text-secondary title-anim fw-6 mb-24">
|
|
||||||
What you get is what you see
|
|
||||||
</h4>
|
|
||||||
<ul>
|
|
||||||
<li>you have at least three years</li>
|
|
||||||
<li>you have a strong web/UI portfolio including</li>
|
|
||||||
<li>fluent English in verbal</li>
|
|
||||||
<li>you are passionate about user interface</li>
|
|
||||||
<li>issues are challenges</li>
|
|
||||||
<li>you are a seeker</li>
|
|
||||||
<li>fluent English in verbal and written communication</li>
|
|
||||||
<li>
|
|
||||||
you are passionate about user interface and web design
|
|
||||||
</li>
|
|
||||||
<li>issues are challenges not show-stoppers for you</li>
|
|
||||||
<li>you are a trend seeker</li>
|
|
||||||
<li>you bring a lot of attention to details</li>
|
|
||||||
<li>
|
|
||||||
you plan upfront, think ahead, and are ready to be surprised
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-lg-5 col-xxl-4 offset-xxl-1">
|
|
||||||
<div className="j-d-sidebar sticky-item">
|
|
||||||
<div className="intro">
|
|
||||||
<span className="text-uppercase mt-8 text-tertiary mb-16">
|
|
||||||
JOIN US
|
|
||||||
</span>
|
|
||||||
<h4 className="mt-8 fw-5 title-anim text-secondary mb-16">
|
|
||||||
UI/UX Design
|
|
||||||
</h4>
|
|
||||||
<p>Full-time (40hr per week)</p>
|
|
||||||
</div>
|
|
||||||
<div className="content mt-40">
|
|
||||||
<p className="mt-8 fw-5 text-xl text-secondary mb-16">
|
|
||||||
Salary-
|
|
||||||
</p>
|
|
||||||
<p className="mt-8 fw-5 text-xl text-secondary mb-16">
|
|
||||||
$1500-$2000 per month
|
|
||||||
</p>
|
|
||||||
<p className="mt-8 fw-4 text-tertiary mb-30">
|
|
||||||
+ VAT (B2B) + bonuses
|
|
||||||
</p>
|
|
||||||
<p className="mt-8 fw-4 text-tertiary mb-16">Remote / Poznań</p>
|
|
||||||
<p className="mt-8 fw-4 text-tertiary">Start: ASAP</p>
|
|
||||||
</div>
|
|
||||||
<div className="cta mt-60">
|
|
||||||
<Link href="/" className="btn">
|
|
||||||
Apply Now
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Job Content Section */}
|
||||||
|
<section className="job-single pb-80 pb-md-100 pb-lg-120 sticky-wrapper" style={{ marginTop: 'clamp(-30px, -5vw, -40px)' }}>
|
||||||
|
<div className="container">
|
||||||
|
<div className="row vertical-column-gap">
|
||||||
|
<div className="col-12 col-lg-8 mb-4 mb-lg-0">
|
||||||
|
<div className="j-d-content" style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 'clamp(8px, 2vw, 12px)',
|
||||||
|
padding: 'clamp(20px, 4vw, 40px)',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.08)'
|
||||||
|
}}>
|
||||||
|
<div className="intro" style={{
|
||||||
|
borderBottom: '2px solid #f0f0f0',
|
||||||
|
paddingBottom: 'clamp(20px, 3vw, 30px)',
|
||||||
|
marginBottom: 'clamp(20px, 3vw, 30px)'
|
||||||
|
}}>
|
||||||
|
<h3 className="fw-6 mb-12 mb-md-16 text-secondary" style={{ fontSize: 'clamp(18px, 3vw, 24px)' }}>
|
||||||
|
About This Position
|
||||||
|
</h3>
|
||||||
|
{job.short_description && (
|
||||||
|
<p style={{
|
||||||
|
color: '#666',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 16px)'
|
||||||
|
}}>
|
||||||
|
{job.short_description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.about_role && (
|
||||||
|
<div className="group mb-32 mb-md-40">
|
||||||
|
<div className="d-flex align-items-center mb-16 mb-md-20">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(22px, 4vw, 28px)'
|
||||||
|
}}>info</span>
|
||||||
|
<h4 className="mt-8 text-secondary fw-6 mb-0" style={{ fontSize: 'clamp(16px, 3vw, 20px)' }}>
|
||||||
|
About This Role
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
color: '#555',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 16px)'
|
||||||
|
}}>{job.about_role}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.requirements && job.requirements.length > 0 && (
|
||||||
|
<div className="group mb-32 mb-md-40">
|
||||||
|
<div className="d-flex align-items-center mb-16 mb-md-20">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(22px, 4vw, 28px)'
|
||||||
|
}}>task_alt</span>
|
||||||
|
<h4 className="mt-8 text-secondary fw-6 mb-0" style={{ fontSize: 'clamp(16px, 3vw, 20px)' }}>
|
||||||
|
Requirements
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{job.requirements.map((req, index) => (
|
||||||
|
<li key={index} className="mb-2" style={{
|
||||||
|
paddingLeft: 'clamp(20px, 4vw, 30px)',
|
||||||
|
position: 'relative',
|
||||||
|
color: '#555',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 16px)'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: 'clamp(6px, 1.5vw, 8px)',
|
||||||
|
width: 'clamp(5px, 1vw, 6px)',
|
||||||
|
height: 'clamp(5px, 1vw, 6px)',
|
||||||
|
backgroundColor: '#667eea',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}></span>
|
||||||
|
{req}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.responsibilities && job.responsibilities.length > 0 && (
|
||||||
|
<div className="group mb-32 mb-md-40">
|
||||||
|
<div className="d-flex align-items-center mb-16 mb-md-20">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: 'clamp(22px, 4vw, 28px)' }}>assignment</span>
|
||||||
|
<h4 className="mt-8 text-secondary fw-6 mb-0" style={{ fontSize: 'clamp(16px, 3vw, 20px)' }}>
|
||||||
|
Key Responsibilities
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{job.responsibilities.map((resp, index) => (
|
||||||
|
<li key={index} className="mb-2" style={{
|
||||||
|
paddingLeft: 'clamp(20px, 4vw, 30px)',
|
||||||
|
position: 'relative',
|
||||||
|
color: '#555',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 16px)'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: 'clamp(6px, 1.5vw, 8px)',
|
||||||
|
width: 'clamp(5px, 1vw, 6px)',
|
||||||
|
height: 'clamp(5px, 1vw, 6px)',
|
||||||
|
backgroundColor: '#667eea',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}></span>
|
||||||
|
{resp}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.qualifications && job.qualifications.length > 0 && (
|
||||||
|
<div className="group mb-32 mb-md-40">
|
||||||
|
<div className="d-flex align-items-center mb-16 mb-md-20">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: 'clamp(22px, 4vw, 28px)' }}>workspace_premium</span>
|
||||||
|
<h4 className="mt-8 text-secondary fw-6 mb-0" style={{ fontSize: 'clamp(16px, 3vw, 20px)' }}>
|
||||||
|
Qualifications
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{job.qualifications.map((qual, index) => (
|
||||||
|
<li key={index} className="mb-2" style={{
|
||||||
|
paddingLeft: 'clamp(20px, 4vw, 30px)',
|
||||||
|
position: 'relative',
|
||||||
|
color: '#555',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 16px)'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: 'clamp(6px, 1.5vw, 8px)',
|
||||||
|
width: 'clamp(5px, 1vw, 6px)',
|
||||||
|
height: 'clamp(5px, 1vw, 6px)',
|
||||||
|
backgroundColor: '#667eea',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}></span>
|
||||||
|
{qual}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.bonus_points && job.bonus_points.length > 0 && (
|
||||||
|
<div className="group mb-32 mb-md-40">
|
||||||
|
<div className="d-flex align-items-center mb-16 mb-md-20">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: 'clamp(22px, 4vw, 28px)' }}>stars</span>
|
||||||
|
<h4 className="mt-8 text-secondary fw-6 mb-0" style={{ fontSize: 'clamp(16px, 3vw, 20px)' }}>
|
||||||
|
Nice to Have
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{job.bonus_points.map((bonus, index) => (
|
||||||
|
<li key={index} className="mb-2" style={{
|
||||||
|
paddingLeft: 'clamp(20px, 4vw, 30px)',
|
||||||
|
position: 'relative',
|
||||||
|
color: '#555',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 16px)'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: 'clamp(6px, 1.5vw, 8px)',
|
||||||
|
width: 'clamp(5px, 1vw, 6px)',
|
||||||
|
height: 'clamp(5px, 1vw, 6px)',
|
||||||
|
backgroundColor: '#667eea',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}></span>
|
||||||
|
{bonus}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.benefits && job.benefits.length > 0 && (
|
||||||
|
<div className="group mb-32 mb-md-40">
|
||||||
|
<div className="d-flex align-items-center mb-16 mb-md-20">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{ color: '#667eea', fontSize: 'clamp(22px, 4vw, 28px)' }}>card_giftcard</span>
|
||||||
|
<h4 className="mt-8 text-secondary fw-6 mb-0" style={{ fontSize: 'clamp(16px, 3vw, 20px)' }}>
|
||||||
|
What We Offer
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{job.benefits.map((benefit, index) => (
|
||||||
|
<li key={index} className="mb-2" style={{
|
||||||
|
paddingLeft: 'clamp(20px, 4vw, 30px)',
|
||||||
|
position: 'relative',
|
||||||
|
color: '#555',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 16px)'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: 'clamp(6px, 1.5vw, 8px)',
|
||||||
|
width: 'clamp(5px, 1vw, 6px)',
|
||||||
|
height: 'clamp(5px, 1vw, 6px)',
|
||||||
|
backgroundColor: '#667eea',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}></span>
|
||||||
|
{benefit}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12 col-lg-4">
|
||||||
|
<div className="j-d-sidebar" style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 'clamp(8px, 2vw, 12px)',
|
||||||
|
padding: 'clamp(20px, 4vw, 30px)',
|
||||||
|
boxShadow: '0 10px 40px rgba(0,0,0,0.08)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: '20px'
|
||||||
|
}}>
|
||||||
|
<div className="intro mb-20 mb-md-30" style={{
|
||||||
|
borderBottom: '2px solid #f0f0f0',
|
||||||
|
paddingBottom: 'clamp(16px, 3vw, 20px)'
|
||||||
|
}}>
|
||||||
|
<span className="text-uppercase" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(11px, 2vw, 12px)',
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: '2px'
|
||||||
|
}}>
|
||||||
|
JOB DETAILS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="content">
|
||||||
|
<div className="detail-item mb-16 mb-md-24">
|
||||||
|
<div className="d-flex align-items-center mb-6 mb-md-8">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(18px, 3vw, 20px)'
|
||||||
|
}}>payments</span>
|
||||||
|
<p className="fw-6 mb-0" style={{
|
||||||
|
color: '#333',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 15px)'
|
||||||
|
}}>Salary Range</p>
|
||||||
|
</div>
|
||||||
|
<p className="fw-5 mb-0" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(16px, 3vw, 18px)'
|
||||||
|
}}>
|
||||||
|
{formatSalary()}
|
||||||
|
</p>
|
||||||
|
{job.salary_additional && (
|
||||||
|
<p className="mt-6 mt-md-8" style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 'clamp(12px, 2vw, 14px)'
|
||||||
|
}}>
|
||||||
|
{job.salary_additional}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-item mb-16 mb-md-24">
|
||||||
|
<div className="d-flex align-items-center mb-6 mb-md-8">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(18px, 3vw, 20px)'
|
||||||
|
}}>work</span>
|
||||||
|
<p className="fw-6 mb-0" style={{
|
||||||
|
color: '#333',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 15px)'
|
||||||
|
}}>Employment Type</p>
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 'clamp(13px, 2vw, 14px)'
|
||||||
|
}}>
|
||||||
|
{job.employment_type.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-item mb-16 mb-md-24">
|
||||||
|
<div className="d-flex align-items-center mb-6 mb-md-8">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(18px, 3vw, 20px)'
|
||||||
|
}}>location_on</span>
|
||||||
|
<p className="fw-6 mb-0" style={{
|
||||||
|
color: '#333',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 15px)'
|
||||||
|
}}>Location</p>
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 'clamp(13px, 2vw, 14px)'
|
||||||
|
}}>{job.location}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-item mb-16 mb-md-24">
|
||||||
|
<div className="d-flex align-items-center mb-6 mb-md-8">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(18px, 3vw, 20px)'
|
||||||
|
}}>event</span>
|
||||||
|
<p className="fw-6 mb-0" style={{
|
||||||
|
color: '#333',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 15px)'
|
||||||
|
}}>Start Date</p>
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 'clamp(13px, 2vw, 14px)'
|
||||||
|
}}>{job.start_date}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-item mb-16 mb-md-24">
|
||||||
|
<div className="d-flex align-items-center mb-6 mb-md-8">
|
||||||
|
<span className="material-symbols-outlined me-2" style={{
|
||||||
|
color: '#667eea',
|
||||||
|
fontSize: 'clamp(18px, 3vw, 20px)'
|
||||||
|
}}>groups</span>
|
||||||
|
<p className="fw-6 mb-0" style={{
|
||||||
|
color: '#333',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 15px)'
|
||||||
|
}}>Openings</p>
|
||||||
|
</div>
|
||||||
|
<p style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 'clamp(13px, 2vw, 14px)'
|
||||||
|
}}>
|
||||||
|
{job.open_positions} {job.open_positions === 1 ? 'Position' : 'Positions'} Available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cta mt-20 mt-md-30">
|
||||||
|
<button
|
||||||
|
onClick={scrollToForm}
|
||||||
|
className="btn w-100 apply-btn"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
color: '#333',
|
||||||
|
border: '2px solid #667eea',
|
||||||
|
padding: 'clamp(12px, 2vw, 15px) clamp(20px, 4vw, 30px)',
|
||||||
|
fontSize: 'clamp(14px, 2vw, 16px)',
|
||||||
|
fontWeight: '600',
|
||||||
|
borderRadius: 'clamp(6px, 1.5vw, 8px)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#FFD700';
|
||||||
|
e.currentTarget.style.borderColor = '#FFD700';
|
||||||
|
e.currentTarget.style.color = '#333';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'white';
|
||||||
|
e.currentTarget.style.borderColor = '#667eea';
|
||||||
|
e.currentTarget.style.color = '#333';
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="d-none d-sm-inline">Apply for This Position</span>
|
||||||
|
<span className="d-sm-none">Apply Now</span>
|
||||||
|
</button>
|
||||||
|
<p className="text-center mt-12 mt-md-16" style={{
|
||||||
|
color: '#999',
|
||||||
|
fontSize: 'clamp(11px, 2vw, 13px)'
|
||||||
|
}}>
|
||||||
|
<span className="d-none d-sm-inline">Application takes ~5 minutes</span>
|
||||||
|
<span className="d-sm-none">~5 min</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/career"
|
||||||
|
className="btn w-100 mt-12 mt-md-16"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#667eea',
|
||||||
|
border: '2px solid #e0e0e0',
|
||||||
|
padding: 'clamp(10px, 2vw, 12px) clamp(20px, 4vw, 30px)',
|
||||||
|
fontSize: 'clamp(13px, 2vw, 14px)',
|
||||||
|
fontWeight: '500',
|
||||||
|
borderRadius: 'clamp(6px, 1.5vw, 8px)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 'clamp(6px, 1.5vw, 8px)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
||||||
|
e.currentTarget.style.borderColor = '#667eea';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 'clamp(16px, 3vw, 18px)' }}>arrow_back</span>
|
||||||
|
<span className="d-none d-sm-inline">Back to Career Page</span>
|
||||||
|
<span className="d-sm-none">Back</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Application Form Modal/Popup */}
|
||||||
|
{showApplicationForm && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop Overlay */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
zIndex: 9998,
|
||||||
|
animation: 'fadeIn 0.3s ease-in-out'
|
||||||
|
}}
|
||||||
|
onClick={() => setShowApplicationForm(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Container */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="application-form-title"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 'clamp(10px, 2vw, 20px)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
animation: 'fadeIn 0.3s ease-in-out'
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Close when clicking the container background
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowApplicationForm(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Close on ESC key
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setShowApplicationForm(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) {
|
||||||
|
setTimeout(() => el.focus(), 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '900px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
position: 'relative',
|
||||||
|
animation: 'slideUp 0.3s ease-out',
|
||||||
|
outline: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<JobApplicationForm job={job} onClose={() => setShowApplicationForm(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Animation Styles */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useJobs } from "@/lib/hooks/useCareer";
|
||||||
|
|
||||||
const OpenPosition = () => {
|
const OpenPosition = () => {
|
||||||
|
const { jobs, loading, error } = useJobs();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section className="op-position pt-120 pb-120" id="scroll-to">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="intro mb-60">
|
||||||
|
<h2 className="mt-8 fw-7 title-anim text-secondary">
|
||||||
|
Open Positions
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 mt-60">
|
||||||
|
<p className="text-center">Loading positions...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<section className="op-position pt-120 pb-120" id="scroll-to">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="intro mb-60">
|
||||||
|
<h2 className="mt-8 fw-7 title-anim text-secondary">
|
||||||
|
Open Positions
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 mt-60">
|
||||||
|
<p className="text-center text-danger">Error loading positions. Please try again later.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="op-position pt-120 pb-120" id="scroll-to">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="intro mb-60">
|
||||||
|
<h2 className="mt-8 fw-7 title-anim text-secondary">
|
||||||
|
Open Positions
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 mt-60">
|
||||||
|
<p className="text-center">No open positions at the moment. Please check back later.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="op-position pt-120 pb-120" id="scroll-to">
|
<section className="op-position pt-120 pb-120" id="scroll-to">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -13,188 +81,36 @@ const OpenPosition = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 mt-60">
|
<div className="col-12 mt-60">
|
||||||
<div className="op-position-single appear-down">
|
{jobs.map((job, index) => (
|
||||||
<div className="row vertical-column-gap align-items-center">
|
<div key={job.id} className="op-position-single appear-down">
|
||||||
<div className="col-12 col-sm-2">
|
<div className="row vertical-column-gap align-items-center">
|
||||||
<span className="fw-7 text-xl text-tertiary">01</span>
|
<div className="col-12 col-sm-2">
|
||||||
</div>
|
<span className="fw-7 text-xl text-tertiary">
|
||||||
<div className="col-12 col-sm-5">
|
{String(index + 1).padStart(2, '0')}
|
||||||
<h4 className="fw-7">
|
|
||||||
<Link href="job-single">UI/UX Design</Link>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-3">
|
|
||||||
<div className="roles">
|
|
||||||
<span className="text-tertiary fw-5 text-xl">
|
|
||||||
(04 Open Roles)
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="col-12 col-sm-5">
|
||||||
<div className="col-12 col-sm-2">
|
<h4 className="fw-7">
|
||||||
<div className="cta text-start text-sm-end">
|
<Link href={`/career/${job.slug}`}>{job.title}</Link>
|
||||||
<Link href="job-single">
|
</h4>
|
||||||
<span className="material-symbols-outlined">east</span>
|
</div>
|
||||||
</Link>
|
<div className="col-12 col-sm-3">
|
||||||
|
<div className="roles">
|
||||||
|
<span className="text-tertiary fw-5 text-xl">
|
||||||
|
({job.open_positions.toString().padStart(2, '0')} Open {job.open_positions === 1 ? 'Role' : 'Roles'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-sm-2">
|
||||||
|
<div className="cta text-start text-sm-end">
|
||||||
|
<Link href={`/career/${job.slug}`}>
|
||||||
|
<span className="material-symbols-outlined">east</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div className="op-position-single appear-down">
|
|
||||||
<div className="row vertical-column-gap align-items-center">
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<span className="fw-7 text-xl text-tertiary">02</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-5">
|
|
||||||
<h4 className="fw-7">
|
|
||||||
<Link href="job-single">Administrative Assistant</Link>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-3">
|
|
||||||
<div className="roles">
|
|
||||||
<span className="text-tertiary fw-5 text-xl">
|
|
||||||
(03 Open Roles)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<div className="cta text-start text-sm-end">
|
|
||||||
<Link href="job-single">
|
|
||||||
<span className="material-symbols-outlined">east</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="op-position-single appear-down">
|
|
||||||
<div className="row vertical-column-gap align-items-center">
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<span className="fw-7 text-xl text-tertiary">03</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-5">
|
|
||||||
<h4 className="fw-7">
|
|
||||||
<Link href="job-single">Software Engineer</Link>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-3">
|
|
||||||
<div className="roles">
|
|
||||||
<span className="text-tertiary fw-5 text-xl">
|
|
||||||
(12 Open Roles)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<div className="cta text-start text-sm-end">
|
|
||||||
<Link href="job-single">
|
|
||||||
<span className="material-symbols-outlined">east</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="op-position-single appear-down">
|
|
||||||
<div className="row vertical-column-gap align-items-center">
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<span className="fw-7 text-xl text-tertiary">04</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-5">
|
|
||||||
<h4 className="fw-7">
|
|
||||||
<Link href="job-single">Data Entry Clerk</Link>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-3">
|
|
||||||
<div className="roles">
|
|
||||||
<span className="text-tertiary fw-5 text-xl">
|
|
||||||
(01 Open Roles)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<div className="cta text-start text-sm-end">
|
|
||||||
<Link href="job-single">
|
|
||||||
<span className="material-symbols-outlined">east</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="op-position-single appear-down">
|
|
||||||
<div className="row vertical-column-gap align-items-center">
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<span className="fw-7 text-xl text-tertiary">05</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-5">
|
|
||||||
<h4 className="fw-7">
|
|
||||||
<Link href="job-single">Marketing Manager</Link>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-3">
|
|
||||||
<div className="roles">
|
|
||||||
<span className="text-tertiary fw-5 text-xl">
|
|
||||||
(09 Open Roles)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<div className="cta text-start text-sm-end">
|
|
||||||
<Link href="job-single">
|
|
||||||
<span className="material-symbols-outlined">east</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="op-position-single appear-down">
|
|
||||||
<div className="row vertical-column-gap align-items-center">
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<span className="fw-7 text-xl text-tertiary">06</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-5">
|
|
||||||
<h4 className="fw-7">
|
|
||||||
<Link href="job-single">Executive Assistant</Link>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-3">
|
|
||||||
<div className="roles">
|
|
||||||
<span className="text-tertiary fw-5 text-xl">
|
|
||||||
(07 Open Roles)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<div className="cta text-start text-sm-end">
|
|
||||||
<Link href="job-single">
|
|
||||||
<span className="material-symbols-outlined">east</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="op-position-single appear-down">
|
|
||||||
<div className="row vertical-column-gap align-items-center">
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<span className="fw-7 text-xl text-tertiary">07</span>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-5">
|
|
||||||
<h4 className="fw-7">
|
|
||||||
<Link href="job-single">Lead Product Designer</Link>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-3">
|
|
||||||
<div className="roles">
|
|
||||||
<span className="text-tertiary fw-5 text-xl">
|
|
||||||
(03 Open Roles)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-sm-2">
|
|
||||||
<div className="cta text-start text-sm-end">
|
|
||||||
<Link href="job-single">
|
|
||||||
<span className="material-symbols-outlined">east</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
300
gnx-react/components/pages/support/CreateTicketForm.tsx
Normal file
300
gnx-react/components/pages/support/CreateTicketForm.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { createTicket, CreateTicketData } from '@/lib/api/supportService';
|
||||||
|
import { useTicketCategories } from '@/lib/hooks/useSupport';
|
||||||
|
|
||||||
|
const CreateTicketForm = () => {
|
||||||
|
const { categories, loading: categoriesLoading } = useTicketCategories();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<CreateTicketData>({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
ticket_type: 'general',
|
||||||
|
user_name: '',
|
||||||
|
user_email: '',
|
||||||
|
user_phone: '',
|
||||||
|
company: '',
|
||||||
|
category: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||||
|
const [ticketNumber, setTicketNumber] = useState<string>('');
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: name === 'category' ? (value ? parseInt(value) : undefined) : value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
setSubmitSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await createTicket(formData);
|
||||||
|
setTicketNumber(response.ticket_number);
|
||||||
|
setSubmitSuccess(true);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
ticket_type: 'general',
|
||||||
|
user_name: '',
|
||||||
|
user_email: '',
|
||||||
|
user_phone: '',
|
||||||
|
company: '',
|
||||||
|
category: undefined
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
setSubmitError(error.message || 'Failed to submit ticket. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="ticket-success">
|
||||||
|
<div className="success-icon">
|
||||||
|
<i className="fa-solid fa-circle-check"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Ticket Created Successfully!</h3>
|
||||||
|
<p className="ticket-number">Your ticket number: <strong>{ticketNumber}</strong></p>
|
||||||
|
<p className="ticket-info">
|
||||||
|
We've received your support request and will respond as soon as possible.
|
||||||
|
Please save your ticket number for future reference.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setSubmitSuccess(false)}
|
||||||
|
>
|
||||||
|
Submit Another Ticket
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="create-ticket-form">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-12 col-lg-10 col-xl-8">
|
||||||
|
<div className="form-header">
|
||||||
|
<h2>Submit a Support Ticket</h2>
|
||||||
|
<p>Fill out the form below and our team will get back to you shortly.</p>
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%)',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginTop: '1rem',
|
||||||
|
fontSize: '0.95rem',
|
||||||
|
color: '#1e293b'
|
||||||
|
}}>
|
||||||
|
<strong>ℹ️ Note:</strong> Only registered email addresses can submit tickets.
|
||||||
|
If your email is not registered, please contact support@gnxsoft.com
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="alert alert-danger" role="alert">
|
||||||
|
<i className="fa-solid fa-triangle-exclamation me-2"></i>
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="support-form">
|
||||||
|
<div className="row g-4">
|
||||||
|
{/* Personal Information */}
|
||||||
|
<div className="col-12">
|
||||||
|
<h4 className="form-section-title">Personal Information</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="user_name">
|
||||||
|
Full Name <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="user_name"
|
||||||
|
name="user_name"
|
||||||
|
value={formData.user_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="form-control"
|
||||||
|
placeholder="John Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="user_email">
|
||||||
|
Email Address <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="user_email"
|
||||||
|
name="user_email"
|
||||||
|
value={formData.user_email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="form-control"
|
||||||
|
placeholder="john@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="user_phone">Phone Number</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="user_phone"
|
||||||
|
name="user_phone"
|
||||||
|
value={formData.user_phone}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="form-control"
|
||||||
|
placeholder="+1 (555) 123-4567"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="company">Company Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="company"
|
||||||
|
name="company"
|
||||||
|
value={formData.company}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Your Company Inc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket Details */}
|
||||||
|
<div className="col-12">
|
||||||
|
<h4 className="form-section-title">Ticket Details</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="ticket_type">
|
||||||
|
Issue Type <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ticket_type"
|
||||||
|
name="ticket_type"
|
||||||
|
value={formData.ticket_type}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="form-control"
|
||||||
|
>
|
||||||
|
<option value="general">General Inquiry</option>
|
||||||
|
<option value="technical">Technical Issue</option>
|
||||||
|
<option value="billing">Billing Question</option>
|
||||||
|
<option value="feature_request">Feature Request</option>
|
||||||
|
<option value="bug_report">Bug Report</option>
|
||||||
|
<option value="account">Account Issue</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="category">Category</label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
name="category"
|
||||||
|
value={formData.category || ''}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="form-control"
|
||||||
|
disabled={categoriesLoading}
|
||||||
|
>
|
||||||
|
<option value="">Select a category</option>
|
||||||
|
{Array.isArray(categories) && categories.map(cat => (
|
||||||
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="title">
|
||||||
|
Subject <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Brief description of your issue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="description">
|
||||||
|
Description <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="form-control"
|
||||||
|
rows={6}
|
||||||
|
placeholder="Please provide detailed information about your issue..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-lg"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-paper-plane me-2"></i>
|
||||||
|
Submit Ticket
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateTicketForm;
|
||||||
|
|
||||||
217
gnx-react/components/pages/support/KnowledgeBase.tsx
Normal file
217
gnx-react/components/pages/support/KnowledgeBase.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useKnowledgeBaseCategories, useFeaturedArticles, useKnowledgeBaseArticles } from '@/lib/hooks/useSupport';
|
||||||
|
import KnowledgeBaseArticleModal from './KnowledgeBaseArticleModal';
|
||||||
|
|
||||||
|
const KnowledgeBase = () => {
|
||||||
|
const { categories, loading: categoriesLoading } = useKnowledgeBaseCategories();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
const [selectedArticleSlug, setSelectedArticleSlug] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch all articles (for browsing and category filtering)
|
||||||
|
const { articles: allArticles, loading: allArticlesLoading } = useKnowledgeBaseArticles();
|
||||||
|
|
||||||
|
// Fetch featured articles (for default view)
|
||||||
|
const { articles: featuredArticles, loading: featuredLoading } = useFeaturedArticles();
|
||||||
|
|
||||||
|
// Determine which articles to display
|
||||||
|
let displayArticles = featuredArticles;
|
||||||
|
let isLoading = featuredLoading;
|
||||||
|
let headerText = 'Featured Articles';
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
// If searching, filter all articles by search term
|
||||||
|
displayArticles = allArticles.filter(article =>
|
||||||
|
article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
article.summary.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
article.content.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
isLoading = allArticlesLoading;
|
||||||
|
headerText = 'Search Results';
|
||||||
|
} else if (selectedCategory) {
|
||||||
|
// If a category is selected, filter articles by that category
|
||||||
|
displayArticles = allArticles.filter(article => article.category_slug === selectedCategory);
|
||||||
|
isLoading = allArticlesLoading;
|
||||||
|
const categoryName = categories.find(cat => cat.slug === selectedCategory)?.name || 'Category';
|
||||||
|
headerText = `${categoryName} Articles`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// The search is already being performed by the hook
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCategories = selectedCategory
|
||||||
|
? categories.filter(cat => cat.slug === selectedCategory)
|
||||||
|
: categories;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="knowledge-base">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-12 col-lg-10">
|
||||||
|
<div className="form-header text-center">
|
||||||
|
<h2>Knowledge Base</h2>
|
||||||
|
<p>Find answers to frequently asked questions and explore our documentation.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<form onSubmit={handleSearch} className="kb-search-form">
|
||||||
|
<div className="search-input-group">
|
||||||
|
<i className="fa-solid fa-search search-icon"></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search articles, topics, or keywords..."
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clear-search"
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-times"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
{!searchTerm && (
|
||||||
|
<div className="kb-categories">
|
||||||
|
<h3>Browse by Category</h3>
|
||||||
|
{categoriesLoading ? (
|
||||||
|
<div className="loading-state">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-4">
|
||||||
|
{Array.isArray(categories) && categories.map(category => (
|
||||||
|
<div key={category.id} className="col-md-6 col-lg-4">
|
||||||
|
<div
|
||||||
|
className="category-card"
|
||||||
|
onClick={() => setSelectedCategory(category.slug)}
|
||||||
|
style={{ borderLeftColor: category.color }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="category-icon"
|
||||||
|
style={{ color: category.color }}
|
||||||
|
>
|
||||||
|
<i className={`fa-solid ${category.icon}`}></i>
|
||||||
|
</div>
|
||||||
|
<div className="category-content">
|
||||||
|
<h4>{category.name}</h4>
|
||||||
|
<p>{category.description}</p>
|
||||||
|
<div className="category-meta">
|
||||||
|
<span className="article-count">
|
||||||
|
{category.article_count} {category.article_count === 1 ? 'article' : 'articles'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Featured/Search Results Articles */}
|
||||||
|
<div className="kb-articles">
|
||||||
|
<div className="articles-header">
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<h3>{headerText}</h3>
|
||||||
|
{selectedCategory && !searchTerm && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
onClick={() => setSelectedCategory(null)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-arrow-left me-2"></i>
|
||||||
|
Back to All Articles
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{searchTerm && (
|
||||||
|
<p className="search-info">
|
||||||
|
Found {displayArticles.length} {displayArticles.length === 1 ? 'article' : 'articles'} for "{searchTerm}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading-state">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : displayArticles.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<i className="fa-solid fa-search empty-icon"></i>
|
||||||
|
<h4>No articles found</h4>
|
||||||
|
<p>
|
||||||
|
{searchTerm
|
||||||
|
? `We couldn't find any articles matching "${searchTerm}". Try different keywords.`
|
||||||
|
: 'No articles available at the moment.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="articles-list">
|
||||||
|
{Array.isArray(displayArticles) && displayArticles.map(article => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="article-item"
|
||||||
|
onClick={() => setSelectedArticleSlug(article.slug)}
|
||||||
|
>
|
||||||
|
<div className="article-header">
|
||||||
|
<h4>{article.title}</h4>
|
||||||
|
{article.is_featured && (
|
||||||
|
<span className="featured-badge">
|
||||||
|
<i className="fa-solid fa-star me-1"></i>
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="article-summary">{article.summary}</p>
|
||||||
|
<div className="article-meta">
|
||||||
|
<span className="article-category">
|
||||||
|
<i className="fa-solid fa-folder me-1"></i>
|
||||||
|
{article.category_name}
|
||||||
|
</span>
|
||||||
|
<span className="article-stats">
|
||||||
|
<i className="fa-solid fa-eye me-1"></i>
|
||||||
|
{article.view_count} views
|
||||||
|
</span>
|
||||||
|
<span className="article-stats">
|
||||||
|
<i className="fa-solid fa-thumbs-up me-1"></i>
|
||||||
|
{article.helpful_count} helpful
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="article-read-more">
|
||||||
|
Read More <i className="fa-solid fa-arrow-right ms-2"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article Modal */}
|
||||||
|
{selectedArticleSlug && (
|
||||||
|
<KnowledgeBaseArticleModal
|
||||||
|
slug={selectedArticleSlug}
|
||||||
|
onClose={() => setSelectedArticleSlug(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KnowledgeBase;
|
||||||
|
|
||||||
139
gnx-react/components/pages/support/KnowledgeBaseArticleModal.tsx
Normal file
139
gnx-react/components/pages/support/KnowledgeBaseArticleModal.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useKnowledgeBaseArticle } from '@/lib/hooks/useSupport';
|
||||||
|
import { markArticleHelpful } from '@/lib/api/supportService';
|
||||||
|
|
||||||
|
interface KnowledgeBaseArticleModalProps {
|
||||||
|
slug: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KnowledgeBaseArticleModal = ({ slug, onClose }: KnowledgeBaseArticleModalProps) => {
|
||||||
|
const { article, loading, error } = useKnowledgeBaseArticle(slug);
|
||||||
|
const [feedbackGiven, setFeedbackGiven] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prevent body scroll when modal is open
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFeedback = async (helpful: boolean) => {
|
||||||
|
if (!article || feedbackGiven) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markArticleHelpful(slug, helpful);
|
||||||
|
setFeedbackGiven(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting feedback:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kb-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="kb-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close" onClick={onClose} aria-label="Close modal">
|
||||||
|
<i className="fa-solid fa-times"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="modal-content">
|
||||||
|
{loading && (
|
||||||
|
<div className="loading-state">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p>Loading article...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-state">
|
||||||
|
<i className="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
<h3>Error Loading Article</h3>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{article && (
|
||||||
|
<>
|
||||||
|
<div className="article-header">
|
||||||
|
{article.is_featured && (
|
||||||
|
<span className="featured-badge">
|
||||||
|
<i className="fa-solid fa-star me-1"></i>
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h2>{article.title}</h2>
|
||||||
|
<div className="article-meta">
|
||||||
|
<span className="meta-item">
|
||||||
|
<i className="fa-solid fa-folder me-1"></i>
|
||||||
|
{article.category_name}
|
||||||
|
</span>
|
||||||
|
<span className="meta-item">
|
||||||
|
<i className="fa-solid fa-calendar me-1"></i>
|
||||||
|
{formatDate(article.published_at || article.created_at)}
|
||||||
|
</span>
|
||||||
|
<span className="meta-item">
|
||||||
|
<i className="fa-solid fa-eye me-1"></i>
|
||||||
|
{article.view_count} views
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="article-body">
|
||||||
|
<div
|
||||||
|
className="article-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: article.content || article.summary }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="article-footer">
|
||||||
|
<div className="article-feedback">
|
||||||
|
<h4>Was this article helpful?</h4>
|
||||||
|
{feedbackGiven ? (
|
||||||
|
<p className="feedback-thanks">
|
||||||
|
<i className="fa-solid fa-check-circle me-2"></i>
|
||||||
|
Thank you for your feedback!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="feedback-buttons">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-success"
|
||||||
|
onClick={() => handleFeedback(true)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-thumbs-up me-2"></i>
|
||||||
|
Yes ({article.helpful_count})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
onClick={() => handleFeedback(false)}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-thumbs-down me-2"></i>
|
||||||
|
No ({article.not_helpful_count})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KnowledgeBaseArticleModal;
|
||||||
|
|
||||||
55
gnx-react/components/pages/support/SupportCenterContent.tsx
Normal file
55
gnx-react/components/pages/support/SupportCenterContent.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from 'react';
|
||||||
|
import CreateTicketForm from './CreateTicketForm';
|
||||||
|
import KnowledgeBase from './KnowledgeBase';
|
||||||
|
import TicketStatusCheck from './TicketStatusCheck';
|
||||||
|
|
||||||
|
type TabType = 'create' | 'knowledge' | 'status';
|
||||||
|
|
||||||
|
const SupportCenterContent = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('create');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="support-center-content section-padding">
|
||||||
|
<div className="container">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-12">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="support-tabs">
|
||||||
|
<ul className="support-tabs__nav">
|
||||||
|
<li className={activeTab === 'create' ? 'active' : ''}>
|
||||||
|
<button onClick={() => setActiveTab('create')}>
|
||||||
|
<i className="fa-solid fa-ticket me-2"></i>
|
||||||
|
Submit a Ticket
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={activeTab === 'knowledge' ? 'active' : ''}>
|
||||||
|
<button onClick={() => setActiveTab('knowledge')}>
|
||||||
|
<i className="fa-solid fa-book me-2"></i>
|
||||||
|
Knowledge Base
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className={activeTab === 'status' ? 'active' : ''}>
|
||||||
|
<button onClick={() => setActiveTab('status')}>
|
||||||
|
<i className="fa-solid fa-search me-2"></i>
|
||||||
|
Check Ticket Status
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="support-tabs__content">
|
||||||
|
{activeTab === 'create' && <CreateTicketForm />}
|
||||||
|
{activeTab === 'knowledge' && <KnowledgeBase />}
|
||||||
|
{activeTab === 'status' && <TicketStatusCheck />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupportCenterContent;
|
||||||
|
|
||||||
91
gnx-react/components/pages/support/SupportCenterHero.tsx
Normal file
91
gnx-react/components/pages/support/SupportCenterHero.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
const SupportCenterHero = () => {
|
||||||
|
return (
|
||||||
|
<section className="support-hero">
|
||||||
|
{/* Animated Background */}
|
||||||
|
<div className="hero-background">
|
||||||
|
{/* Floating Support Icons */}
|
||||||
|
<div className="floating-tech tech-1">
|
||||||
|
<i className="fa-solid fa-headset"></i>
|
||||||
|
</div>
|
||||||
|
<div className="floating-tech tech-2">
|
||||||
|
<i className="fa-solid fa-ticket"></i>
|
||||||
|
</div>
|
||||||
|
<div className="floating-tech tech-3">
|
||||||
|
<i className="fa-solid fa-book"></i>
|
||||||
|
</div>
|
||||||
|
<div className="floating-tech tech-4">
|
||||||
|
<i className="fa-solid fa-comments"></i>
|
||||||
|
</div>
|
||||||
|
<div className="floating-tech tech-5">
|
||||||
|
<i className="fa-solid fa-life-ring"></i>
|
||||||
|
</div>
|
||||||
|
<div className="floating-tech tech-6">
|
||||||
|
<i className="fa-solid fa-user-shield"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid Pattern */}
|
||||||
|
<div className="grid-overlay"></div>
|
||||||
|
|
||||||
|
{/* Animated Gradient Orbs */}
|
||||||
|
<div className="gradient-orb orb-1"></div>
|
||||||
|
<div className="gradient-orb orb-2"></div>
|
||||||
|
<div className="gradient-orb orb-3"></div>
|
||||||
|
|
||||||
|
{/* Video Overlay */}
|
||||||
|
<div className="video-overlay"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="container">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-12 col-lg-10 col-xl-8">
|
||||||
|
<div className="support-hero__content text-center">
|
||||||
|
<h1 className="support-hero__title">
|
||||||
|
Support Center
|
||||||
|
</h1>
|
||||||
|
<p className="support-hero__subtitle">
|
||||||
|
Get expert assistance whenever you need it. Our dedicated support team is here to help you succeed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="support-hero__features">
|
||||||
|
<div className="row g-4 justify-content-center">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="feature-item">
|
||||||
|
<div className="feature-icon">
|
||||||
|
<i className="fa-solid fa-ticket"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Submit Tickets</h3>
|
||||||
|
<p>Create and track support requests</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="feature-item">
|
||||||
|
<div className="feature-icon">
|
||||||
|
<i className="fa-solid fa-book"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Knowledge Base</h3>
|
||||||
|
<p>Find answers to common questions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="feature-item">
|
||||||
|
<div className="feature-icon">
|
||||||
|
<i className="fa-solid fa-search"></i>
|
||||||
|
</div>
|
||||||
|
<h3>Track Status</h3>
|
||||||
|
<p>Monitor your ticket progress</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupportCenterHero;
|
||||||
|
|
||||||
197
gnx-react/components/pages/support/TicketStatusCheck.tsx
Normal file
197
gnx-react/components/pages/support/TicketStatusCheck.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import { checkTicketStatus, SupportTicket } from '@/lib/api/supportService';
|
||||||
|
|
||||||
|
const TicketStatusCheck = () => {
|
||||||
|
const [ticketNumber, setTicketNumber] = useState('');
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
const [ticket, setTicket] = useState<SupportTicket | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
setTicket(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await checkTicketStatus(ticketNumber);
|
||||||
|
setTicket(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Ticket not found') {
|
||||||
|
setSearchError('Ticket not found. Please check your ticket number and try again.');
|
||||||
|
} else {
|
||||||
|
setSearchError('An error occurred while searching. Please try again.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ticket-status-check">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-12 col-lg-8">
|
||||||
|
<div className="form-header text-center">
|
||||||
|
<h2>Check Ticket Status</h2>
|
||||||
|
<p>Enter your ticket number to view the current status and details of your support request.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="status-search-form">
|
||||||
|
<div className="search-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ticketNumber}
|
||||||
|
onChange={(e) => setTicketNumber(e.target.value)}
|
||||||
|
placeholder="Enter your ticket number (e.g., TKT-20231015-ABCDE)"
|
||||||
|
required
|
||||||
|
className="form-control"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isSearching}
|
||||||
|
>
|
||||||
|
{isSearching ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
|
Searching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="fa-solid fa-search me-2"></i>
|
||||||
|
Check Status
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<div className="alert alert-danger mt-4" role="alert">
|
||||||
|
<i className="fa-solid fa-triangle-exclamation me-2"></i>
|
||||||
|
{searchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ticket && (
|
||||||
|
<div className="ticket-details">
|
||||||
|
<div className="ticket-header">
|
||||||
|
<div className="ticket-number">
|
||||||
|
<i className="fa-solid fa-ticket me-2"></i>
|
||||||
|
{ticket.ticket_number}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="ticket-status-badge"
|
||||||
|
style={{ backgroundColor: ticket.status_color }}
|
||||||
|
>
|
||||||
|
{ticket.status_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ticket-info">
|
||||||
|
<h3>{ticket.title}</h3>
|
||||||
|
<div className="ticket-meta">
|
||||||
|
<div className="meta-item">
|
||||||
|
<i className="fa-solid fa-calendar me-2"></i>
|
||||||
|
<strong>Created:</strong> {formatDate(ticket.created_at)}
|
||||||
|
</div>
|
||||||
|
<div className="meta-item">
|
||||||
|
<i className="fa-solid fa-clock me-2"></i>
|
||||||
|
<strong>Last Updated:</strong> {formatDate(ticket.updated_at)}
|
||||||
|
</div>
|
||||||
|
{ticket.priority_name && (
|
||||||
|
<div className="meta-item">
|
||||||
|
<i className="fa-solid fa-flag me-2"></i>
|
||||||
|
<strong>Priority:</strong>
|
||||||
|
<span
|
||||||
|
className="priority-badge ms-2"
|
||||||
|
style={{ backgroundColor: ticket.priority_color }}
|
||||||
|
>
|
||||||
|
{ticket.priority_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ticket.category_name && (
|
||||||
|
<div className="meta-item">
|
||||||
|
<i className="fa-solid fa-folder me-2"></i>
|
||||||
|
<strong>Category:</strong> {ticket.category_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ticket-description">
|
||||||
|
<h4>Description</h4>
|
||||||
|
<p>{ticket.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ticket.messages && ticket.messages.length > 0 && (
|
||||||
|
<div className="ticket-messages">
|
||||||
|
<h4>Messages ({ticket.messages.length})</h4>
|
||||||
|
<div className="messages-list">
|
||||||
|
{ticket.messages
|
||||||
|
.filter(msg => !msg.is_internal)
|
||||||
|
.map((message, index) => (
|
||||||
|
<div key={message.id} className="message-item">
|
||||||
|
<div className="message-header">
|
||||||
|
<div className="message-author">
|
||||||
|
<i className="fa-solid fa-user-circle me-2"></i>
|
||||||
|
{message.author_name || message.author_email}
|
||||||
|
</div>
|
||||||
|
<div className="message-date">
|
||||||
|
{formatDate(message.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="message-content">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ticket.activities && ticket.activities.length > 0 && (
|
||||||
|
<div className="ticket-timeline">
|
||||||
|
<h4>Activity Timeline</h4>
|
||||||
|
<div className="timeline-list">
|
||||||
|
{ticket.activities.slice(0, 5).map((activity, index) => (
|
||||||
|
<div key={activity.id} className="timeline-item">
|
||||||
|
<div className="timeline-icon">
|
||||||
|
<i className="fa-solid fa-circle"></i>
|
||||||
|
</div>
|
||||||
|
<div className="timeline-content">
|
||||||
|
<div className="timeline-description">
|
||||||
|
{activity.description}
|
||||||
|
</div>
|
||||||
|
<div className="timeline-date">
|
||||||
|
{formatDate(activity.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TicketStatusCheck;
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ const Header = () => {
|
|||||||
navigationType: "both",
|
navigationType: "both",
|
||||||
headerClass: "tp-header",
|
headerClass: "tp-header",
|
||||||
scrolledClass: "navbar-active",
|
scrolledClass: "navbar-active",
|
||||||
buttonText: "Get Started",
|
buttonText: "Support Center",
|
||||||
buttonUrl: "/contact-us",
|
buttonUrl: "/support-center",
|
||||||
buttonClass: "btn btn-primary d-none d-sm-flex",
|
buttonClass: "btn btn-primary d-none d-sm-flex",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 1,
|
displayOrder: 1,
|
||||||
|
|||||||
237
gnx-react/lib/api/careerService.ts
Normal file
237
gnx-react/lib/api/careerService.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { API_BASE_URL } from '../config/api';
|
||||||
|
|
||||||
|
export interface JobPosition {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
department: string;
|
||||||
|
employment_type: string;
|
||||||
|
location_type: string;
|
||||||
|
location: string;
|
||||||
|
open_positions: number;
|
||||||
|
experience_required?: string;
|
||||||
|
salary_min?: number;
|
||||||
|
salary_max?: number;
|
||||||
|
salary_currency: string;
|
||||||
|
salary_period: string;
|
||||||
|
salary_additional?: string;
|
||||||
|
short_description?: string;
|
||||||
|
about_role?: string;
|
||||||
|
requirements?: string[];
|
||||||
|
responsibilities?: string[];
|
||||||
|
qualifications?: string[];
|
||||||
|
bonus_points?: string[];
|
||||||
|
benefits?: string[];
|
||||||
|
start_date: string;
|
||||||
|
posted_date: string;
|
||||||
|
updated_date: string;
|
||||||
|
deadline?: string;
|
||||||
|
status: string;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobApplication {
|
||||||
|
job: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
current_position?: string;
|
||||||
|
current_company?: string;
|
||||||
|
years_of_experience?: string;
|
||||||
|
cover_letter?: string;
|
||||||
|
resume: File;
|
||||||
|
portfolio_url?: string;
|
||||||
|
linkedin_url?: string;
|
||||||
|
github_url?: string;
|
||||||
|
website_url?: string;
|
||||||
|
available_from?: string;
|
||||||
|
notice_period?: string;
|
||||||
|
expected_salary?: number;
|
||||||
|
salary_currency?: string;
|
||||||
|
consent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CareerService {
|
||||||
|
private baseUrl = `${API_BASE_URL}/api/career`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active job positions
|
||||||
|
*/
|
||||||
|
async getAllJobs(): Promise<JobPosition[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/jobs/`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch jobs: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response - extract results array
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching jobs:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single job position by slug
|
||||||
|
*/
|
||||||
|
async getJobBySlug(slug: string): Promise<JobPosition> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/jobs/${slug}/`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch job: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get featured job positions
|
||||||
|
*/
|
||||||
|
async getFeaturedJobs(): Promise<JobPosition[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/jobs/featured/`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch featured jobs: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response - extract results array
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching featured jobs:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a job application
|
||||||
|
*/
|
||||||
|
async submitApplication(applicationData: JobApplication): Promise<any> {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Append all fields to FormData
|
||||||
|
formData.append('job', applicationData.job.toString());
|
||||||
|
formData.append('first_name', applicationData.first_name);
|
||||||
|
formData.append('last_name', applicationData.last_name);
|
||||||
|
formData.append('email', applicationData.email);
|
||||||
|
formData.append('consent', applicationData.consent.toString());
|
||||||
|
|
||||||
|
// Append resume file
|
||||||
|
if (applicationData.resume) {
|
||||||
|
formData.append('resume', applicationData.resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append optional fields
|
||||||
|
if (applicationData.phone) formData.append('phone', applicationData.phone);
|
||||||
|
if (applicationData.current_position) formData.append('current_position', applicationData.current_position);
|
||||||
|
if (applicationData.current_company) formData.append('current_company', applicationData.current_company);
|
||||||
|
if (applicationData.years_of_experience) formData.append('years_of_experience', applicationData.years_of_experience);
|
||||||
|
if (applicationData.cover_letter) formData.append('cover_letter', applicationData.cover_letter);
|
||||||
|
if (applicationData.portfolio_url) formData.append('portfolio_url', applicationData.portfolio_url);
|
||||||
|
if (applicationData.linkedin_url) formData.append('linkedin_url', applicationData.linkedin_url);
|
||||||
|
if (applicationData.github_url) formData.append('github_url', applicationData.github_url);
|
||||||
|
if (applicationData.website_url) formData.append('website_url', applicationData.website_url);
|
||||||
|
if (applicationData.available_from) formData.append('available_from', applicationData.available_from);
|
||||||
|
if (applicationData.notice_period) formData.append('notice_period', applicationData.notice_period);
|
||||||
|
if (applicationData.expected_salary) formData.append('expected_salary', applicationData.expected_salary.toString());
|
||||||
|
if (applicationData.salary_currency) formData.append('salary_currency', applicationData.salary_currency);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/applications/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || `Failed to submit application: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting application:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter jobs by department
|
||||||
|
*/
|
||||||
|
async getJobsByDepartment(department: string): Promise<JobPosition[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/jobs/?department=${department}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch jobs: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response - extract results array
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching jobs by department:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter jobs by employment type
|
||||||
|
*/
|
||||||
|
async getJobsByEmploymentType(employmentType: string): Promise<JobPosition[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/jobs/?employment_type=${employmentType}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch jobs: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response - extract results array
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching jobs by employment type:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const careerService = new CareerService();
|
||||||
|
|
||||||
406
gnx-react/lib/api/supportService.ts
Normal file
406
gnx-react/lib/api/supportService.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import { API_CONFIG } from '../config/api';
|
||||||
|
|
||||||
|
const BASE_URL = `${API_CONFIG.BASE_URL}/api/support`;
|
||||||
|
|
||||||
|
// ==================== Types ====================
|
||||||
|
export interface TicketStatus {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
description: string;
|
||||||
|
is_closed: boolean;
|
||||||
|
display_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketPriority {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
level: number;
|
||||||
|
color: string;
|
||||||
|
description: string;
|
||||||
|
sla_hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
icon: string;
|
||||||
|
display_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketMessage {
|
||||||
|
id: number;
|
||||||
|
ticket: number;
|
||||||
|
message_type: string;
|
||||||
|
content: string;
|
||||||
|
author_name: string;
|
||||||
|
author_email: string;
|
||||||
|
is_internal: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
attachments: string[];
|
||||||
|
is_read: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketActivity {
|
||||||
|
id: number;
|
||||||
|
activity_type: string;
|
||||||
|
description: string;
|
||||||
|
user_name: string;
|
||||||
|
old_value: string;
|
||||||
|
new_value: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportTicket {
|
||||||
|
id: number;
|
||||||
|
ticket_number: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ticket_type: string;
|
||||||
|
user_name: string;
|
||||||
|
user_email: string;
|
||||||
|
user_phone: string;
|
||||||
|
company: string;
|
||||||
|
category: number | null;
|
||||||
|
category_name: string;
|
||||||
|
priority: number | null;
|
||||||
|
priority_name: string;
|
||||||
|
priority_color: string;
|
||||||
|
status: number | null;
|
||||||
|
status_name: string;
|
||||||
|
status_color: string;
|
||||||
|
assigned_to: number | null;
|
||||||
|
assigned_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
closed_at: string | null;
|
||||||
|
last_activity: string;
|
||||||
|
first_response_at: string | null;
|
||||||
|
sla_deadline: string | null;
|
||||||
|
tags: string;
|
||||||
|
is_escalated: boolean;
|
||||||
|
escalation_reason: string;
|
||||||
|
attachments: string[];
|
||||||
|
messages: TicketMessage[];
|
||||||
|
activities: TicketActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTicketData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ticket_type: string;
|
||||||
|
user_name: string;
|
||||||
|
user_email: string;
|
||||||
|
user_phone?: string;
|
||||||
|
company?: string;
|
||||||
|
category?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBaseCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
display_order: number;
|
||||||
|
article_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBaseArticle {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
category: number;
|
||||||
|
category_name: string;
|
||||||
|
category_slug: string;
|
||||||
|
content?: string;
|
||||||
|
summary: string;
|
||||||
|
meta_description?: string;
|
||||||
|
keywords?: string;
|
||||||
|
is_featured: boolean;
|
||||||
|
view_count: number;
|
||||||
|
helpful_count: number;
|
||||||
|
not_helpful_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
published_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportSettings {
|
||||||
|
id: number;
|
||||||
|
setting_name: string;
|
||||||
|
setting_value: string;
|
||||||
|
description: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API Functions ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all ticket categories
|
||||||
|
*/
|
||||||
|
export const getTicketCategories = async (): Promise<TicketCategory[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/categories/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch ticket categories');
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching ticket categories:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all ticket statuses
|
||||||
|
*/
|
||||||
|
export const getTicketStatuses = async (): Promise<TicketStatus[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/statuses/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch ticket statuses');
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching ticket statuses:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all ticket priorities
|
||||||
|
*/
|
||||||
|
export const getTicketPriorities = async (): Promise<TicketPriority[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/priorities/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch ticket priorities');
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching ticket priorities:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new support ticket
|
||||||
|
*/
|
||||||
|
export const createTicket = async (data: CreateTicketData): Promise<SupportTicket> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/tickets/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (response.status === 400 && errorData.user_email) {
|
||||||
|
throw new Error(errorData.user_email[0] || 'Email validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorData.detail || errorData.message || 'Failed to create ticket');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating ticket:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check ticket status by ticket number
|
||||||
|
*/
|
||||||
|
export const checkTicketStatus = async (ticketNumber: string): Promise<SupportTicket> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/tickets/check-status/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ticket_number: ticketNumber }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error('Ticket not found');
|
||||||
|
}
|
||||||
|
throw new Error('Failed to check ticket status');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking ticket status:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a message to a ticket
|
||||||
|
*/
|
||||||
|
export const addTicketMessage = async (
|
||||||
|
ticketId: number,
|
||||||
|
content: string,
|
||||||
|
authorName: string,
|
||||||
|
authorEmail: string
|
||||||
|
): Promise<TicketMessage> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/tickets/${ticketId}/add-message/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content,
|
||||||
|
author_name: authorName,
|
||||||
|
author_email: authorEmail,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to add ticket message');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding ticket message:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all knowledge base categories
|
||||||
|
*/
|
||||||
|
export const getKnowledgeBaseCategories = async (): Promise<KnowledgeBaseCategory[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/knowledge-base-categories/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch knowledge base categories');
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching knowledge base categories:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all knowledge base articles
|
||||||
|
*/
|
||||||
|
export const getKnowledgeBaseArticles = async (search?: string): Promise<KnowledgeBaseArticle[]> => {
|
||||||
|
try {
|
||||||
|
const url = search
|
||||||
|
? `${BASE_URL}/knowledge-base/?search=${encodeURIComponent(search)}`
|
||||||
|
: `${BASE_URL}/knowledge-base/`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch knowledge base articles');
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching knowledge base articles:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch featured knowledge base articles
|
||||||
|
*/
|
||||||
|
export const getFeaturedArticles = async (): Promise<KnowledgeBaseArticle[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/knowledge-base/featured/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch featured articles');
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle both array and paginated responses
|
||||||
|
return Array.isArray(data) ? data : (data.results || data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching featured articles:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single knowledge base article by slug
|
||||||
|
*/
|
||||||
|
export const getKnowledgeBaseArticle = async (slug: string): Promise<KnowledgeBaseArticle> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/knowledge-base/${slug}/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch knowledge base article');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching knowledge base article:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch articles by category
|
||||||
|
*/
|
||||||
|
export const getArticlesByCategory = async (categorySlug: string): Promise<KnowledgeBaseArticle[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/knowledge-base/by-category/${categorySlug}/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch articles by category');
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching articles by category:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an article as helpful or not helpful
|
||||||
|
*/
|
||||||
|
export const markArticleHelpful = async (slug: string, helpful: boolean): Promise<{ helpful_count: number; not_helpful_count: number }> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/knowledge-base/${slug}/mark-helpful/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ helpful }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to mark article helpful');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking article helpful:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch support settings
|
||||||
|
*/
|
||||||
|
export const getSupportSettings = async (): Promise<SupportSettings[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/settings/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch support settings');
|
||||||
|
const data = await response.json();
|
||||||
|
// Handle paginated response
|
||||||
|
return data.results || data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching support settings:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific support setting by name
|
||||||
|
*/
|
||||||
|
export const getSupportSetting = async (settingName: string): Promise<SupportSettings> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/settings/${settingName}/`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch support setting');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching support setting:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
95
gnx-react/lib/hooks/useCareer.ts
Normal file
95
gnx-react/lib/hooks/useCareer.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { careerService, JobPosition } from '../api/careerService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to fetch all job positions
|
||||||
|
*/
|
||||||
|
export const useJobs = () => {
|
||||||
|
const [jobs, setJobs] = useState<JobPosition[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchJobs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await careerService.getAllJobs();
|
||||||
|
setJobs(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
console.error('Error fetching jobs:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchJobs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { jobs, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to fetch a single job by slug
|
||||||
|
*/
|
||||||
|
export const useJob = (slug: string) => {
|
||||||
|
const [job, setJob] = useState<JobPosition | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchJob = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await careerService.getJobBySlug(slug);
|
||||||
|
setJob(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
console.error('Error fetching job:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchJob();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
return { job, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to fetch featured jobs
|
||||||
|
*/
|
||||||
|
export const useFeaturedJobs = () => {
|
||||||
|
const [jobs, setJobs] = useState<JobPosition[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchFeaturedJobs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await careerService.getFeaturedJobs();
|
||||||
|
setJobs(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
console.error('Error fetching featured jobs:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFeaturedJobs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { jobs, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
251
gnx-react/lib/hooks/useSupport.ts
Normal file
251
gnx-react/lib/hooks/useSupport.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
getTicketCategories,
|
||||||
|
getTicketStatuses,
|
||||||
|
getTicketPriorities,
|
||||||
|
getKnowledgeBaseCategories,
|
||||||
|
getKnowledgeBaseArticles,
|
||||||
|
getFeaturedArticles,
|
||||||
|
getKnowledgeBaseArticle,
|
||||||
|
getArticlesByCategory,
|
||||||
|
TicketCategory,
|
||||||
|
TicketStatus,
|
||||||
|
TicketPriority,
|
||||||
|
KnowledgeBaseCategory,
|
||||||
|
KnowledgeBaseArticle
|
||||||
|
} from '../api/supportService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch ticket categories
|
||||||
|
*/
|
||||||
|
export const useTicketCategories = () => {
|
||||||
|
const [categories, setCategories] = useState<TicketCategory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getTicketCategories();
|
||||||
|
setCategories(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch ticket categories');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { categories, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch ticket statuses
|
||||||
|
*/
|
||||||
|
export const useTicketStatuses = () => {
|
||||||
|
const [statuses, setStatuses] = useState<TicketStatus[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStatuses = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getTicketStatuses();
|
||||||
|
setStatuses(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch ticket statuses');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStatuses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { statuses, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch ticket priorities
|
||||||
|
*/
|
||||||
|
export const useTicketPriorities = () => {
|
||||||
|
const [priorities, setPriorities] = useState<TicketPriority[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPriorities = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getTicketPriorities();
|
||||||
|
setPriorities(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch ticket priorities');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPriorities();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { priorities, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch knowledge base categories
|
||||||
|
*/
|
||||||
|
export const useKnowledgeBaseCategories = () => {
|
||||||
|
const [categories, setCategories] = useState<KnowledgeBaseCategory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getKnowledgeBaseCategories();
|
||||||
|
setCategories(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch knowledge base categories');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { categories, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch knowledge base articles with optional search
|
||||||
|
*/
|
||||||
|
export const useKnowledgeBaseArticles = (search?: string) => {
|
||||||
|
const [articles, setArticles] = useState<KnowledgeBaseArticle[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchArticles = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getKnowledgeBaseArticles(search);
|
||||||
|
setArticles(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch knowledge base articles');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchArticles();
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return { articles, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch featured knowledge base articles
|
||||||
|
*/
|
||||||
|
export const useFeaturedArticles = () => {
|
||||||
|
const [articles, setArticles] = useState<KnowledgeBaseArticle[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchArticles = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getFeaturedArticles();
|
||||||
|
setArticles(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch featured articles');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchArticles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { articles, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch a single knowledge base article
|
||||||
|
*/
|
||||||
|
export const useKnowledgeBaseArticle = (slug: string | null) => {
|
||||||
|
const [article, setArticle] = useState<KnowledgeBaseArticle | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchArticle = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getKnowledgeBaseArticle(slug);
|
||||||
|
setArticle(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch knowledge base article');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchArticle();
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
return { article, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch articles by category
|
||||||
|
*/
|
||||||
|
export const useArticlesByCategory = (categorySlug: string | null) => {
|
||||||
|
const [articles, setArticles] = useState<KnowledgeBaseArticle[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!categorySlug) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchArticles = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getArticlesByCategory(categorySlug);
|
||||||
|
setArticles(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to fetch articles by category');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchArticles();
|
||||||
|
}, [categorySlug]);
|
||||||
|
|
||||||
|
return { articles, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user