feat: Add comprehensive sandbox mode, public API system, and platform support
This commit adds major features for sandbox isolation, public API access, and platform support ticketing. ## Sandbox Mode - Add sandbox mode toggle for businesses to test features without affecting live data - Implement schema-based isolation for tenant data (appointments, resources, services) - Add is_sandbox field filtering for shared models (customers, staff, tickets) - Create sandbox middleware to detect and set sandbox mode from cookies - Add sandbox context and hooks for React frontend - Display sandbox banner when in test mode - Auto-reload page when switching between live/test modes - Prevent platform support tickets from being created in sandbox mode ## Public API System - Full REST API for external integrations with businesses - API token management with sandbox/live token separation - Test tokens (ss_test_*) show full plaintext for easy testing - Live tokens (ss_live_*) are hashed and secure - Security validation prevents live token plaintext storage - Comprehensive test suite for token security - Rate limiting and throttling per token - Webhook support for real-time event notifications - Scoped permissions system (read/write per resource type) - API documentation page with interactive examples - Token revocation with confirmation modal ## Platform Support - Dedicated support page for businesses to contact SmoothSchedule - View all platform support tickets in one place - Create new support tickets with simplified interface - Reply to existing tickets with conversation history - Platform tickets have no admin controls (no priority/category/assignee/status) - Internal notes hidden for platform tickets (business can't see them) - Quick help section with links to guides and API docs - Sandbox warning prevents ticket creation in test mode - Business ticketing retains full admin controls (priority, assignment, internal notes) ## UI/UX Improvements - Add notification dropdown with real-time updates - Staff permissions UI for ticket access control - Help dropdown in sidebar with Platform Guide, Ticketing Help, API Docs, and Support - Update sidebar "Contact Support" to "Support" with message icon - Fix navigation links to use React Router instead of anchor tags - Remove unused language translations (Japanese, Portuguese, Chinese) ## Technical Details - Sandbox middleware sets request.sandbox_mode from cookies - ViewSets filter data by is_sandbox field - API authentication via custom token auth class - WebSocket support for real-time ticket updates - Migration for sandbox fields on User, Tenant, and Ticket models - Comprehensive documentation in SANDBOX_MODE_IMPLEMENTATION.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1789
frontend/src/pages/HelpApiDocs.tsx
Normal file
1789
frontend/src/pages/HelpApiDocs.tsx
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/src/pages/HelpGuide.tsx
Normal file
33
frontend/src/pages/HelpGuide.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BookOpen, Construction } from 'lucide-react';
|
||||
|
||||
const HelpGuide: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<BookOpen className="text-brand-600" />
|
||||
{t('help.guide.title', 'Platform Guide')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-2">
|
||||
{t('help.guide.subtitle', 'Learn how to use SmoothSchedule effectively')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<Construction size={64} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('help.guide.comingSoon', 'Coming Soon')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
{t('help.guide.comingSoonDesc', 'We are working on comprehensive documentation to help you get the most out of SmoothSchedule. Check back soon!')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpGuide;
|
||||
422
frontend/src/pages/HelpTicketing.tsx
Normal file
422
frontend/src/pages/HelpTicketing.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Ticket,
|
||||
MessageSquare,
|
||||
Users,
|
||||
Shield,
|
||||
Bell,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
HelpCircle,
|
||||
ChevronRight,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
const HelpTicketing: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto py-8 px-4">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-6"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<BookOpen size={24} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.title', 'Ticketing System Guide')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.subtitle', 'Learn how to use the support ticketing system')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Ticket size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.overview.title', 'Overview')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t('helpTicketing.overview.description',
|
||||
'The ticketing system allows you to manage support requests, customer inquiries, staff requests, and internal communications all in one place. Tickets can be categorized, prioritized, and assigned to team members for efficient handling.'
|
||||
)}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<MessageSquare size={20} className="text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.overview.customerSupport', 'Customer Support')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.overview.customerSupportDesc', 'Handle customer inquiries, complaints, and refund requests')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Users size={20} className="text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.overview.staffRequests', 'Staff Requests')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.overview.staffRequestsDesc', 'Manage time-off requests, schedule changes, and equipment needs')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<Shield size={20} className="text-purple-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.overview.internal', 'Internal Tickets')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.overview.internalDesc', 'Track internal issues and communications within your team')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<HelpCircle size={20} className="text-orange-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('helpTicketing.overview.platformSupport', 'Platform Support')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('helpTicketing.overview.platformSupportDesc', 'Get help from the SmoothSchedule support team')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ticket Types Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<MessageSquare size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.ticketTypes.title', 'Ticket Types')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('helpTicketing.ticketTypes.type', 'Type')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('helpTicketing.ticketTypes.description', 'Description')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t('helpTicketing.ticketTypes.categories', 'Categories')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Customer
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.ticketTypes.customerDesc', 'Requests and inquiries from your customers')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Appointment, Refund, Complaint, General Inquiry
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
Staff Request
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.ticketTypes.staffDesc', 'Internal requests from your staff members')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Time Off, Schedule Change, Equipment
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
Internal
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.ticketTypes.internalDesc', 'Internal team communications and issues')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Equipment, General Inquiry, Other
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||
Platform
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.ticketTypes.platformDesc', 'Support requests to SmoothSchedule team')}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Billing, Technical, Feature Request, Account
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ticket Statuses Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Clock size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.statuses.title', 'Ticket Statuses')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 min-w-[140px]">
|
||||
<AlertCircle size={14} /> Open
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.openDesc', 'Ticket has been submitted and is waiting to be reviewed')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 min-w-[140px]">
|
||||
<Clock size={14} /> In Progress
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.inProgressDesc', 'Ticket is being actively worked on by a team member')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 min-w-[140px]">
|
||||
<HelpCircle size={14} /> Awaiting Response
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.awaitingDesc', 'Waiting for additional information from the requester')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 min-w-[140px]">
|
||||
<CheckCircle size={14} /> Resolved
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.resolvedDesc', 'Issue has been resolved but ticket remains open for follow-up')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 min-w-[140px]">
|
||||
<CheckCircle size={14} /> Closed
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{t('helpTicketing.statuses.closedDesc', 'Ticket has been completed and closed')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Priority Levels Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<AlertCircle size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.priorities.title', 'Priority Levels')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Low
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{t('helpTicketing.priorities.lowDesc', 'General inquiries and non-urgent requests. No immediate action required.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
Medium
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{t('helpTicketing.priorities.mediumDesc', 'Standard requests that should be addressed within normal business hours.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400">
|
||||
High
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{t('helpTicketing.priorities.highDesc', 'Important issues that require prompt attention and resolution.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<span className="px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400">
|
||||
Urgent
|
||||
</span>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{t('helpTicketing.priorities.urgentDesc', 'Critical issues requiring immediate attention. Business-impacting problems.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Permissions Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Shield size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.permissions.title', 'Access & Permissions')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('helpTicketing.permissions.ownersManagers', 'Business Owners & Managers')}
|
||||
</h4>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li>{t('helpTicketing.permissions.ownerPerm1', 'View and manage all tickets for your business')}</li>
|
||||
<li>{t('helpTicketing.permissions.ownerPerm2', 'Assign tickets to staff members')}</li>
|
||||
<li>{t('helpTicketing.permissions.ownerPerm3', 'Change ticket status and priority')}</li>
|
||||
<li>{t('helpTicketing.permissions.ownerPerm4', 'Add comments (public and internal)')}</li>
|
||||
<li>{t('helpTicketing.permissions.ownerPerm5', 'Control staff access to the ticketing system')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('helpTicketing.permissions.staff', 'Staff Members')}
|
||||
</h4>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li>{t('helpTicketing.permissions.staffPerm1', 'Access requires permission from owner/manager')}</li>
|
||||
<li>{t('helpTicketing.permissions.staffPerm2', 'View tickets assigned to them or in their department')}</li>
|
||||
<li>{t('helpTicketing.permissions.staffPerm3', 'Update ticket status and add comments')}</li>
|
||||
<li>{t('helpTicketing.permissions.staffPerm4', 'Create new support requests')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('helpTicketing.permissions.customers', 'Customers')}
|
||||
</h4>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-300 space-y-1 ml-4">
|
||||
<li>{t('helpTicketing.permissions.customerPerm1', 'Submit support requests through the Support page')}</li>
|
||||
<li>{t('helpTicketing.permissions.customerPerm2', 'View only their own submitted tickets')}</li>
|
||||
<li>{t('helpTicketing.permissions.customerPerm3', 'Track the status of their requests')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Bell size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.notifications.title', 'Notifications')}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t('helpTicketing.notifications.description',
|
||||
'The system automatically sends notifications for important ticket events. You will receive notifications for:'
|
||||
)}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-gray-600 dark:text-gray-300 space-y-2 ml-4">
|
||||
<li>{t('helpTicketing.notifications.event1', 'New tickets assigned to you')}</li>
|
||||
<li>{t('helpTicketing.notifications.event2', 'Comments added to your tickets')}</li>
|
||||
<li>{t('helpTicketing.notifications.event3', 'Status changes on tickets you created or are assigned to')}</li>
|
||||
<li>{t('helpTicketing.notifications.event4', 'Priority escalations')}</li>
|
||||
</ul>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
||||
{t('helpTicketing.notifications.bellIcon',
|
||||
'Access your notifications by clicking the bell icon in the navigation bar.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Tips */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<CheckCircle size={20} className="text-brand-500" />
|
||||
{t('helpTicketing.tips.title', 'Quick Tips')}
|
||||
</h2>
|
||||
<div className="bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-900/20 dark:to-blue-900/20 rounded-xl border border-brand-200 dark:border-brand-800 p-6">
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip1', 'Use clear, descriptive subjects to help prioritize and categorize tickets quickly.')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip2', 'Assign tickets to specific team members to ensure accountability.')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip3', 'Use internal comments for team discussions that customers should not see.')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip4', 'Regularly review and close resolved tickets to keep your queue organized.')}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{t('helpTicketing.tips.tip5', 'Set appropriate priorities to ensure urgent issues are addressed first.')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Need More Help */}
|
||||
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 text-center">
|
||||
<HelpCircle size={32} className="mx-auto text-brand-500 mb-3" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('helpTicketing.moreHelp.title', 'Need More Help?')}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4">
|
||||
{t('helpTicketing.moreHelp.description',
|
||||
"If you have questions about the ticketing system that aren't covered here, please submit a Platform Support ticket and our team will assist you."
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/tickets')}
|
||||
className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
{t('helpTicketing.moreHelp.goToTickets', 'Go to Tickets')}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpTicketing;
|
||||
400
frontend/src/pages/PlatformSupport.tsx
Normal file
400
frontend/src/pages/PlatformSupport.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory } from '../types';
|
||||
import { useTickets, useTicketComments, useCreateTicketComment } from '../hooks/useTickets';
|
||||
import { MessageSquare, Plus, Clock, CheckCircle, AlertCircle, HelpCircle, ChevronRight, Send, User as UserIcon, BookOpen, Code, LifeBuoy } from 'lucide-react';
|
||||
import TicketModal from '../components/TicketModal';
|
||||
import { useSandbox } from '../contexts/SandboxContext';
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge: React.FC<{ status: TicketStatus }> = ({ status }) => {
|
||||
const { t } = useTranslation();
|
||||
const statusConfig: Record<TicketStatus, { color: string; icon: React.ReactNode }> = {
|
||||
OPEN: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: <AlertCircle size={12} /> },
|
||||
IN_PROGRESS: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', icon: <Clock size={12} /> },
|
||||
AWAITING_RESPONSE: { color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', icon: <HelpCircle size={12} /> },
|
||||
RESOLVED: { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: <CheckCircle size={12} /> },
|
||||
CLOSED: { color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', icon: <CheckCircle size={12} /> },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.icon}
|
||||
{t(`tickets.status.${status.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Priority badge component
|
||||
const PriorityBadge: React.FC<{ priority: TicketPriority }> = ({ priority }) => {
|
||||
const { t } = useTranslation();
|
||||
const priorityConfig: Record<TicketPriority, string> = {
|
||||
LOW: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
MEDIUM: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
HIGH: 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
URGENT: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${priorityConfig[priority]}`}>
|
||||
{t(`tickets.priorities.${priority.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Ticket detail view (read-only for business users viewing platform tickets)
|
||||
const TicketDetail: React.FC<{ ticket: Ticket; onBack: () => void }> = ({ ticket, onBack }) => {
|
||||
const { t } = useTranslation();
|
||||
const [replyText, setReplyText] = useState('');
|
||||
|
||||
// Fetch comments for this ticket
|
||||
const { data: comments = [], isLoading: isLoadingComments } = useTicketComments(ticket.id);
|
||||
const createCommentMutation = useCreateTicketComment();
|
||||
|
||||
// Filter out internal comments (business users shouldn't see platform's internal notes)
|
||||
const visibleComments = comments.filter((comment: TicketComment) => !comment.isInternal);
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyText.trim()) return;
|
||||
|
||||
await createCommentMutation.mutateAsync({
|
||||
ticketId: ticket.id,
|
||||
commentData: {
|
||||
commentText: replyText.trim(),
|
||||
isInternal: false, // Business replies are never internal
|
||||
},
|
||||
});
|
||||
setReplyText('');
|
||||
};
|
||||
|
||||
const isTicketClosed = ticket.status === 'CLOSED';
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-2 flex items-center gap-1"
|
||||
>
|
||||
← {t('common.back', 'Back to support tickets')}
|
||||
</button>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{ticket.subject}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('tickets.ticketNumber', 'Ticket #{{number}}', { number: ticket.ticketNumber })}
|
||||
{' • '}
|
||||
{t('tickets.createdAt', 'Created {{date}}', { date: new Date(ticket.createdAt).toLocaleDateString() })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ticket.status} />
|
||||
<PriorityBadge priority={ticket.priority} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t('tickets.description')}</h3>
|
||||
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">{ticket.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
{ticket.status === 'OPEN' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('platformSupport.statusOpen', 'Your request has been received. Our support team will review it shortly.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'IN_PROGRESS' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('platformSupport.statusInProgress', 'Our support team is currently working on your request.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'AWAITING_RESPONSE' && (
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t('platformSupport.statusAwaitingResponse', 'We need additional information from you. Please reply below.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'RESOLVED' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('platformSupport.statusResolved', 'Your request has been resolved. Thank you for contacting SmoothSchedule support!')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'CLOSED' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('platformSupport.statusClosed', 'This ticket has been closed.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments / Conversation */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
<MessageSquare size={16} />
|
||||
{t('platformSupport.conversation', 'Conversation')}
|
||||
</h3>
|
||||
|
||||
{isLoadingComments ? (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : visibleComments.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 py-4">
|
||||
{t('platformSupport.noRepliesYet', 'No replies yet. Our support team will respond soon.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
{visibleComments.map((comment: TicketComment) => (
|
||||
<div key={comment.id} className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<UserIcon size={14} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{comment.authorFullName || comment.authorEmail}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap pl-10">
|
||||
{comment.commentText}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
{!isTicketClosed ? (
|
||||
<div className="px-6 py-4">
|
||||
<form onSubmit={handleSubmitReply} className="space-y-3">
|
||||
<label htmlFor="reply" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('platformSupport.yourReply', 'Your Reply')}
|
||||
</label>
|
||||
<textarea
|
||||
id="reply"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={t('platformSupport.replyPlaceholder', 'Type your message here...')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
disabled={createCommentMutation.isPending}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createCommentMutation.isPending || !replyText.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send size={16} />
|
||||
{createCommentMutation.isPending
|
||||
? t('common.sending', 'Sending...')
|
||||
: t('platformSupport.sendReply', 'Send Reply')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.ticketClosedNoReply', 'This ticket is closed. If you need further assistance, please open a new support request.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PlatformSupport: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isSandbox } = useSandbox();
|
||||
const [showNewTicketModal, setShowNewTicketModal] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
|
||||
const { data: tickets = [], isLoading, refetch } = useTickets();
|
||||
|
||||
// Filter to only show platform support tickets
|
||||
const platformTickets = tickets.filter(ticket => ticket.ticketType === 'PLATFORM');
|
||||
|
||||
if (selectedTicket) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('platformSupport.title', 'SmoothSchedule Support')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('platformSupport.subtitle', 'Get help from the SmoothSchedule team')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewTicketModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('platformSupport.newRequest', 'Contact Support')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sandbox Warning Banner */}
|
||||
{isSandbox && (
|
||||
<div className="mb-6 p-4 bg-orange-50 dark:bg-orange-900/20 border-2 border-orange-500 dark:border-orange-600 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle size={20} className="text-orange-600 dark:text-orange-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-orange-800 dark:text-orange-200">
|
||||
{t('platformSupport.sandboxWarning', 'You are in Test Mode')}
|
||||
</h4>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300 mt-1">
|
||||
{t('platformSupport.sandboxWarningMessage', 'Platform support is only available in Live Mode. Switch to Live Mode to contact SmoothSchedule support.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Help Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('platformSupport.quickHelp', 'Quick Help')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Link
|
||||
to="/help/guide"
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<BookOpen size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('platformSupport.platformGuide', 'Platform Guide')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.platformGuideDesc', 'Learn the basics')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/help/api"
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Code size={20} className="text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('platformSupport.apiDocs', 'API Docs')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.apiDocsDesc', 'Integration help')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowNewTicketModal(true)}
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<LifeBuoy size={20} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('platformSupport.contactUs', 'Contact Support')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.contactUsDesc', 'Get personalized help')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My Support Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('platformSupport.myRequests', 'My Support Requests')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : platformTickets.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<MessageSquare size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('platformSupport.noRequests', "You haven't submitted any support requests yet.")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewTicketModal(true)}
|
||||
className="mt-4 text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
||||
>
|
||||
{t('platformSupport.submitFirst', 'Submit your first request')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{platformTickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
onClick={() => setSelectedTicket(ticket)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{ticket.subject}
|
||||
</h3>
|
||||
<StatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('tickets.ticketNumber', 'Ticket #{{number}}', { number: ticket.ticketNumber })}
|
||||
{' • '}
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={20} className="text-gray-400 flex-shrink-0 ml-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Ticket Modal */}
|
||||
{showNewTicketModal && (
|
||||
<TicketModal
|
||||
onClose={() => {
|
||||
setShowNewTicketModal(false);
|
||||
refetch();
|
||||
}}
|
||||
defaultTicketType="PLATFORM"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformSupport;
|
||||
@@ -9,6 +9,7 @@ import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyC
|
||||
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
|
||||
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
import ApiTokensSection from '../components/ApiTokensSection';
|
||||
|
||||
// Curated color palettes with complementary primary and secondary colors
|
||||
const colorPalettes = [
|
||||
@@ -98,7 +99,7 @@ const colorPalettes = [
|
||||
},
|
||||
];
|
||||
|
||||
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources';
|
||||
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources' | 'api-tokens';
|
||||
|
||||
// Resource Types Management Section Component
|
||||
const ResourceTypesSection: React.FC = () => {
|
||||
@@ -645,6 +646,7 @@ const SettingsPage: React.FC = () => {
|
||||
{ id: 'resources' as const, label: 'Resource Types', icon: Layers },
|
||||
{ id: 'domains' as const, label: 'Domains', icon: Globe },
|
||||
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
||||
{ id: 'api-tokens' as const, label: 'API Tokens', icon: Key },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1853,6 +1855,11 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API TOKENS TAB */}
|
||||
{activeTab === 'api-tokens' && isOwner && (
|
||||
<ApiTokensSection />
|
||||
)}
|
||||
|
||||
{/* Floating Action Buttons */}
|
||||
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Power,
|
||||
} from 'lucide-react';
|
||||
import Portal from '../components/Portal';
|
||||
import StaffPermissions from '../components/StaffPermissions';
|
||||
|
||||
interface StaffProps {
|
||||
onMasquerade: (user: User) => void;
|
||||
@@ -535,222 +536,23 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Manager Permissions */}
|
||||
{/* Permissions - Using shared component */}
|
||||
{inviteRole === 'TENANT_MANAGER' && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('staff.managerPermissions', 'Manager Permissions')}
|
||||
</h4>
|
||||
|
||||
{/* Can Invite Staff */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_invite_staff ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_invite_staff: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canInviteStaff', 'Can invite new staff members')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canInviteStaffHint', 'Allow this manager to send invitations to new staff members')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Resources */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_manage_resources ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_manage_resources: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageResources', 'Can manage resources')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageResourcesHint', 'Create, edit, and delete bookable resources')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Services */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_manage_services ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_manage_services: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageServices', 'Can manage services')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageServicesHint', 'Create, edit, and delete service offerings')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can View Reports */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_view_reports ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_view_reports: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canViewReports', 'Can view reports')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canViewReportsHint', 'Access business analytics and financial reports')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Settings */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_access_settings ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_access_settings: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessSettings', 'Can access business settings')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessSettingsHint', 'Modify business profile, branding, and configuration')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Refund Payments */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_refund_payments ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_refund_payments: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canRefundPayments', 'Can refund payments')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canRefundPaymentsHint', 'Process refunds for customer payments')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Tickets */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_access_tickets ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_access_tickets: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessTickets', 'Can access support tickets')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessTicketsHint', 'View and manage customer support tickets')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<StaffPermissions
|
||||
role="manager"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Staff Permissions */}
|
||||
{inviteRole === 'TENANT_STAFF' && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('staff.staffPermissions', 'Staff Permissions')}
|
||||
</h4>
|
||||
|
||||
{/* Can View All Schedules */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_view_all_schedules ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_view_all_schedules: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canViewAllSchedules', 'Can view all schedules')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canViewAllSchedulesHint', 'View schedules of other staff members (otherwise only their own)')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Own Appointments */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_manage_own_appointments ?? true}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_manage_own_appointments: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageOwnAppointments', 'Can manage own appointments')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageOwnAppointmentsHint', 'Create, reschedule, and cancel their own appointments')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Tickets */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invitePermissions.can_access_tickets ?? false}
|
||||
onChange={(e) =>
|
||||
setInvitePermissions({ ...invitePermissions, can_access_tickets: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessTickets', 'Can access support tickets')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessTicketsHint', 'View and manage customer support tickets')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={invitePermissions}
|
||||
onChange={setInvitePermissions}
|
||||
variant="invite"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Make Bookable Option */}
|
||||
@@ -873,222 +675,23 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Manager Permissions Section */}
|
||||
{/* Permissions - Using shared component */}
|
||||
{editingStaff.role === 'manager' && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('staff.managerPermissions', 'Manager Permissions')}
|
||||
</h4>
|
||||
|
||||
{/* Can Invite Staff */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_invite_staff ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_invite_staff: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canInviteStaff', 'Can invite new staff members')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canInviteStaffHint', 'Allow this manager to send invitations to new staff members')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Resources */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_manage_resources ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_manage_resources: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageResources', 'Can manage resources')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageResourcesHint', 'Create, edit, and delete bookable resources')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Services */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_manage_services ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_manage_services: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageServices', 'Can manage services')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageServicesHint', 'Create, edit, and delete service offerings')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can View Reports */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_view_reports ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_view_reports: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canViewReports', 'Can view reports')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canViewReportsHint', 'Access business analytics and financial reports')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Settings */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_access_settings ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_access_settings: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessSettings', 'Can access business settings')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessSettingsHint', 'Modify business profile, branding, and configuration')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Refund Payments */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_refund_payments ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_refund_payments: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canRefundPayments', 'Can refund payments')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canRefundPaymentsHint', 'Process refunds for customer payments')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Tickets */}
|
||||
<label className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_access_tickets ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_access_tickets: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessTickets', 'Can access support tickets')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessTicketsHint', 'View and manage customer support tickets')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<StaffPermissions
|
||||
role="manager"
|
||||
permissions={editPermissions}
|
||||
onChange={setEditPermissions}
|
||||
variant="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Staff Permissions Section (for non-managers) */}
|
||||
{editingStaff.role === 'staff' && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('staff.staffPermissions', 'Staff Permissions')}
|
||||
</h4>
|
||||
|
||||
{/* Can View Own Schedule Only */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_view_all_schedules ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_view_all_schedules: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canViewAllSchedules', 'Can view all schedules')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canViewAllSchedulesHint', 'View schedules of other staff members (otherwise only their own)')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Manage Own Appointments */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_manage_own_appointments ?? true}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_manage_own_appointments: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canManageOwnAppointments', 'Can manage own appointments')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canManageOwnAppointmentsHint', 'Create, reschedule, and cancel their own appointments')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Can Access Tickets */}
|
||||
<label className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editPermissions.can_access_tickets ?? false}
|
||||
onChange={(e) =>
|
||||
setEditPermissions({ ...editPermissions, can_access_tickets: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 mt-0.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t('staff.canAccessTickets', 'Can access support tickets')}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t('staff.canAccessTicketsHint', 'View and manage customer support tickets')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<StaffPermissions
|
||||
role="staff"
|
||||
permissions={editPermissions}
|
||||
onChange={setEditPermissions}
|
||||
variant="edit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No permissions for owners */}
|
||||
|
||||
490
frontend/src/pages/customer/CustomerSupport.tsx
Normal file
490
frontend/src/pages/customer/CustomerSupport.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { User, Business, Ticket, TicketComment, TicketStatus, TicketPriority, TicketCategory } from '../../types';
|
||||
import { useTickets, useCreateTicket, useTicketComments, useCreateTicketComment } from '../../hooks/useTickets';
|
||||
import { MessageSquare, Plus, Clock, CheckCircle, AlertCircle, HelpCircle, ChevronRight, Send, User as UserIcon } from 'lucide-react';
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge: React.FC<{ status: TicketStatus }> = ({ status }) => {
|
||||
const { t } = useTranslation();
|
||||
const statusConfig: Record<TicketStatus, { color: string; icon: React.ReactNode }> = {
|
||||
OPEN: { color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', icon: <AlertCircle size={12} /> },
|
||||
IN_PROGRESS: { color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300', icon: <Clock size={12} /> },
|
||||
AWAITING_RESPONSE: { color: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', icon: <HelpCircle size={12} /> },
|
||||
RESOLVED: { color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', icon: <CheckCircle size={12} /> },
|
||||
CLOSED: { color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', icon: <CheckCircle size={12} /> },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${config.color}`}>
|
||||
{config.icon}
|
||||
{t(`tickets.status.${status.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Priority badge component
|
||||
const PriorityBadge: React.FC<{ priority: TicketPriority }> = ({ priority }) => {
|
||||
const { t } = useTranslation();
|
||||
const priorityConfig: Record<TicketPriority, string> = {
|
||||
LOW: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
MEDIUM: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
HIGH: 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
URGENT: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${priorityConfig[priority]}`}>
|
||||
{t(`tickets.priorities.${priority.toLowerCase()}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// New ticket form component
|
||||
const NewTicketForm: React.FC<{ onClose: () => void; onSuccess: () => void }> = ({ onClose, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [subject, setSubject] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState<TicketCategory>('GENERAL_INQUIRY');
|
||||
const [priority, setPriority] = useState<TicketPriority>('MEDIUM');
|
||||
|
||||
const createTicketMutation = useCreateTicket();
|
||||
|
||||
const categoryOptions: TicketCategory[] = ['APPOINTMENT', 'REFUND', 'COMPLAINT', 'GENERAL_INQUIRY', 'OTHER'];
|
||||
const priorityOptions: TicketPriority[] = ['LOW', 'MEDIUM', 'HIGH', 'URGENT'];
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await createTicketMutation.mutateAsync({
|
||||
subject,
|
||||
description,
|
||||
category,
|
||||
priority,
|
||||
ticketType: 'CUSTOMER',
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('customerSupport.newRequest', 'Submit a Support Request')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('customerSupport.subjectPlaceholder', 'Brief summary of your issue')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.category')}
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as TicketCategory)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
{categoryOptions.map(cat => (
|
||||
<option key={cat} value={cat}>{t(`tickets.categories.${cat.toLowerCase()}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.priority')}
|
||||
</label>
|
||||
<select
|
||||
id="priority"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TicketPriority)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
{priorityOptions.map(opt => (
|
||||
<option key={opt} value={opt}>{t(`tickets.priorities.${opt.toLowerCase()}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('tickets.description')}
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={t('customerSupport.descriptionPlaceholder', 'Please describe your issue in detail...')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createTicketMutation.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{createTicketMutation.isPending ? t('common.saving') : t('customerSupport.submitRequest', 'Submit Request')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Ticket detail view
|
||||
const TicketDetail: React.FC<{ ticket: Ticket; onBack: () => void }> = ({ ticket, onBack }) => {
|
||||
const { t } = useTranslation();
|
||||
const [replyText, setReplyText] = useState('');
|
||||
|
||||
// Fetch comments for this ticket
|
||||
const { data: comments = [], isLoading: isLoadingComments } = useTicketComments(ticket.id);
|
||||
const createCommentMutation = useCreateTicketComment();
|
||||
|
||||
// Filter out internal comments (customers shouldn't see them)
|
||||
const visibleComments = comments.filter((comment: TicketComment) => !comment.isInternal);
|
||||
|
||||
const handleSubmitReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!replyText.trim()) return;
|
||||
|
||||
await createCommentMutation.mutateAsync({
|
||||
ticketId: ticket.id,
|
||||
commentData: {
|
||||
commentText: replyText.trim(),
|
||||
isInternal: false, // Customer replies are never internal
|
||||
},
|
||||
});
|
||||
setReplyText('');
|
||||
};
|
||||
|
||||
const isTicketClosed = ticket.status === 'CLOSED';
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400 mb-2 flex items-center gap-1"
|
||||
>
|
||||
← {t('common.back', 'Back to tickets')}
|
||||
</button>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{ticket.subject}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('tickets.ticketNumber', 'Ticket #{{number}}', { number: ticket.ticketNumber })}
|
||||
{' • '}
|
||||
{t('tickets.createdAt', 'Created {{date}}', { date: new Date(ticket.createdAt).toLocaleDateString() })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ticket.status} />
|
||||
<PriorityBadge priority={ticket.priority} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{t('tickets.description')}</h3>
|
||||
<p className="text-gray-900 dark:text-white whitespace-pre-wrap">{ticket.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
{ticket.status === 'OPEN' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('customerSupport.statusOpen', 'Your request has been received. Our team will review it shortly.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'IN_PROGRESS' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('customerSupport.statusInProgress', 'Our team is currently working on your request.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'AWAITING_RESPONSE' && (
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t('customerSupport.statusAwaitingResponse', 'We need additional information from you. Please reply below.')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'RESOLVED' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('customerSupport.statusResolved', 'Your request has been resolved. Thank you for contacting us!')}
|
||||
</p>
|
||||
)}
|
||||
{ticket.status === 'CLOSED' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('customerSupport.statusClosed', 'This ticket has been closed.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments / Conversation */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4 flex items-center gap-2">
|
||||
<MessageSquare size={16} />
|
||||
{t('customerSupport.conversation', 'Conversation')}
|
||||
</h3>
|
||||
|
||||
{isLoadingComments ? (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : visibleComments.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 py-4">
|
||||
{t('customerSupport.noRepliesYet', 'No replies yet. Our team will respond soon.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
{visibleComments.map((comment: TicketComment) => (
|
||||
<div key={comment.id} className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<UserIcon size={14} className="text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{comment.authorFullName || comment.authorEmail}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{new Date(comment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap pl-10">
|
||||
{comment.commentText}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply Form */}
|
||||
{!isTicketClosed ? (
|
||||
<div className="px-6 py-4">
|
||||
<form onSubmit={handleSubmitReply} className="space-y-3">
|
||||
<label htmlFor="reply" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('customerSupport.yourReply', 'Your Reply')}
|
||||
</label>
|
||||
<textarea
|
||||
id="reply"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={t('customerSupport.replyPlaceholder', 'Type your message here...')}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
disabled={createCommentMutation.isPending}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createCommentMutation.isPending || !replyText.trim()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send size={16} />
|
||||
{createCommentMutation.isPending
|
||||
? t('common.sending', 'Sending...')
|
||||
: t('customerSupport.sendReply', 'Send Reply')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('customerSupport.ticketClosedNoReply', 'This ticket is closed. If you need further assistance, please open a new support request.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomerSupport: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, business } = useOutletContext<{ user: User; business: Business }>();
|
||||
const [showNewTicketForm, setShowNewTicketForm] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
|
||||
const { data: tickets = [], isLoading, refetch } = useTickets();
|
||||
|
||||
// Filter to only show customer's own tickets
|
||||
const myTickets = tickets.filter(ticket => ticket.ticketType === 'CUSTOMER');
|
||||
|
||||
if (selectedTicket) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('customerSupport.title', 'Support')}
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('customerSupport.subtitle', 'Get help with your appointments and account')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewTicketForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{t('customerSupport.newRequest', 'New Request')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Help Section */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('customerSupport.quickHelp', 'Quick Help')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); setShowNewTicketForm(true); }}
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<MessageSquare size={20} className="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('customerSupport.contactUs', 'Contact Us')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('customerSupport.contactUsDesc', 'Submit a support request')}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:support@${business?.subdomain || 'business'}.smoothschedule.com`}
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<HelpCircle size={20} className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{t('customerSupport.emailUs', 'Email Us')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('customerSupport.emailUsDesc', 'Get help via email')}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* My Requests */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('customerSupport.myRequests', 'My Support Requests')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : myTickets.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<MessageSquare size={48} className="mx-auto text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('customerSupport.noRequests', "You haven't submitted any support requests yet.")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewTicketForm(true)}
|
||||
className="mt-4 text-brand-600 hover:text-brand-700 dark:text-brand-400 font-medium"
|
||||
>
|
||||
{t('customerSupport.submitFirst', 'Submit your first request')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myTickets.map((ticket) => (
|
||||
<button
|
||||
key={ticket.id}
|
||||
onClick={() => setSelectedTicket(ticket)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{ticket.subject}
|
||||
</h3>
|
||||
<StatusBadge status={ticket.status} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('tickets.ticketNumber', 'Ticket #{{number}}', { number: ticket.ticketNumber })}
|
||||
{' • '}
|
||||
{new Date(ticket.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={20} className="text-gray-400 flex-shrink-0 ml-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Ticket Modal */}
|
||||
{showNewTicketForm && (
|
||||
<NewTicketForm
|
||||
onClose={() => setShowNewTicketForm(false)}
|
||||
onSuccess={() => refetch()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerSupport;
|
||||
Reference in New Issue
Block a user