feat: bilingual public issue pages with browser detection and language toggle
All checks were successful
Deploy / deploy (push) Successful in 11s
All checks were successful
Deploy / deploy (push) Successful in 11s
Both PublicIssueSubmit and PublicIssueTracker now support EN/AR with auto-detection from browser language, a floating toggle button, RTL layout, and localized dates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,41 +1,149 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AlertCircle, Send, CheckCircle2, Upload, X } from 'lucide-react'
|
||||
import { AlertCircle, Send, CheckCircle2, Upload, X, Globe } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import FormInput from '../components/FormInput'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
{ value: 'correction', label: 'Correction' },
|
||||
{ value: 'complaint', label: 'Complaint' },
|
||||
{ value: 'suggestion', label: 'Suggestion' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
// ─── Bilingual translations ────────────────────────────────────
|
||||
const T = {
|
||||
en: {
|
||||
pageTitle: 'Submit an Issue',
|
||||
pageSubtitle: 'Report issues, request corrections, or make suggestions. We\'ll track your submission and keep you updated.',
|
||||
yourInfo: 'Your Information',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'Your full name',
|
||||
nameRequired: 'Name is required',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'your.email@example.com',
|
||||
emailRequired: 'Email is required',
|
||||
emailInvalid: 'Invalid email address',
|
||||
phone: 'Phone (Optional)',
|
||||
phonePlaceholder: '+966 5X XXX XXXX',
|
||||
teamQuestion: 'Which team should handle your issue?',
|
||||
selectTeam: 'Select a team',
|
||||
issueDetails: 'Issue Details',
|
||||
category: 'Category',
|
||||
categoryPlaceholder: 'e.g., Marketing, IT, Operations',
|
||||
type: 'Type',
|
||||
priority: 'Priority',
|
||||
title: 'Title',
|
||||
titlePlaceholder: 'Brief summary of the issue',
|
||||
titleRequired: 'Title is required',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Provide detailed information about the issue...',
|
||||
descriptionRequired: 'Description is required',
|
||||
attachment: 'Attachment (Optional)',
|
||||
uploadPrompt: 'Click to upload a file (screenshots, documents, etc.)',
|
||||
submit: 'Submit Issue',
|
||||
submitting: 'Submitting...',
|
||||
submitFailed: 'Failed to submit issue. Please try again.',
|
||||
footerNote: 'You\'ll receive a tracking link to monitor the progress of your issue.',
|
||||
// Success page
|
||||
successTitle: 'Issue Submitted Successfully!',
|
||||
successMessage: 'Thank you for submitting your issue. You can track its progress using the link below.',
|
||||
trackingLink: 'Your Tracking Link',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied to clipboard!',
|
||||
trackIssue: 'Track Your Issue',
|
||||
submitAnother: 'Submit Another Issue',
|
||||
// Options
|
||||
request: 'Request', correction: 'Correction', complaint: 'Complaint', suggestion: 'Suggestion', other: 'Other',
|
||||
low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent',
|
||||
},
|
||||
ar: {
|
||||
pageTitle: 'تقديم مشكلة',
|
||||
pageSubtitle: 'أبلغ عن مشاكل، اطلب تصحيحات، أو قدّم اقتراحات. سنتابع طلبك ونبقيك على اطلاع.',
|
||||
yourInfo: 'معلوماتك',
|
||||
name: 'الاسم',
|
||||
namePlaceholder: 'الاسم الكامل',
|
||||
nameRequired: 'الاسم مطلوب',
|
||||
email: 'البريد الإلكتروني',
|
||||
emailPlaceholder: 'your.email@example.com',
|
||||
emailRequired: 'البريد الإلكتروني مطلوب',
|
||||
emailInvalid: 'بريد إلكتروني غير صالح',
|
||||
phone: 'الهاتف (اختياري)',
|
||||
phonePlaceholder: '+966 5X XXX XXXX',
|
||||
teamQuestion: 'أي فريق يجب أن يتعامل مع مشكلتك؟',
|
||||
selectTeam: 'اختر فريقاً',
|
||||
issueDetails: 'تفاصيل المشكلة',
|
||||
category: 'الفئة',
|
||||
categoryPlaceholder: 'مثال: تسويق، تقنية، عمليات',
|
||||
type: 'النوع',
|
||||
priority: 'الأولوية',
|
||||
title: 'العنوان',
|
||||
titlePlaceholder: 'ملخص موجز للمشكلة',
|
||||
titleRequired: 'العنوان مطلوب',
|
||||
description: 'الوصف',
|
||||
descriptionPlaceholder: 'قدّم معلومات مفصلة عن المشكلة...',
|
||||
descriptionRequired: 'الوصف مطلوب',
|
||||
attachment: 'مرفق (اختياري)',
|
||||
uploadPrompt: 'اضغط لرفع ملف (لقطات شاشة، مستندات، إلخ)',
|
||||
submit: 'تقديم المشكلة',
|
||||
submitting: 'جارٍ التقديم...',
|
||||
submitFailed: 'فشل في تقديم المشكلة. يرجى المحاولة مرة أخرى.',
|
||||
footerNote: 'ستتلقى رابط تتبع لمراقبة تقدم مشكلتك.',
|
||||
successTitle: 'تم تقديم المشكلة بنجاح!',
|
||||
successMessage: 'شكراً لتقديم مشكلتك. يمكنك تتبع تقدمها من خلال الرابط أدناه.',
|
||||
trackingLink: 'رابط التتبع',
|
||||
copy: 'نسخ',
|
||||
copied: 'تم النسخ!',
|
||||
trackIssue: 'تتبع مشكلتك',
|
||||
submitAnother: 'تقديم مشكلة أخرى',
|
||||
request: 'طلب', correction: 'تصحيح', complaint: 'شكوى', suggestion: 'اقتراح', other: 'أخرى',
|
||||
low: 'منخفضة', medium: 'متوسطة', high: 'عالية', urgent: 'عاجلة',
|
||||
},
|
||||
}
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'urgent', label: 'Urgent' },
|
||||
]
|
||||
function detectLang() {
|
||||
const nav = navigator.language || navigator.userLanguage || ''
|
||||
return nav.startsWith('ar') ? 'ar' : 'en'
|
||||
}
|
||||
|
||||
function LangToggle({ lang, setLang }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
|
||||
className="fixed top-4 end-4 z-50 flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border rounded-full shadow-sm hover:bg-surface-secondary transition-colors text-sm font-medium text-text-primary"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{lang === 'en' ? 'العربية' : 'English'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PublicIssueSubmit() {
|
||||
const toast = useToast()
|
||||
const [lang, setLang] = useState(detectLang)
|
||||
const t = (key) => T[lang]?.[key] || T.en[key] || key
|
||||
const dir = lang === 'ar' ? 'rtl' : 'ltr'
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = dir
|
||||
document.documentElement.lang = lang
|
||||
return () => { document.documentElement.dir = 'ltr'; document.documentElement.lang = 'en' }
|
||||
}, [lang, dir])
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'request', label: t('request') },
|
||||
{ value: 'correction', label: t('correction') },
|
||||
{ value: 'complaint', label: t('complaint') },
|
||||
{ value: 'suggestion', label: t('suggestion') },
|
||||
{ value: 'other', label: t('other') },
|
||||
]
|
||||
|
||||
const PRIORITY_OPTIONS = [
|
||||
{ value: 'low', label: t('low') },
|
||||
{ value: 'medium', label: t('medium') },
|
||||
{ value: 'high', label: t('high') },
|
||||
{ value: 'urgent', label: t('urgent') },
|
||||
]
|
||||
|
||||
// Team pre-selection from URL
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const teamParam = urlParams.get('team')
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category: 'Marketing',
|
||||
type: 'request',
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: '',
|
||||
team_id: teamParam || '',
|
||||
name: '', email: '', phone: '', category: 'Marketing',
|
||||
type: 'request', priority: 'medium', title: '', description: '', team_id: teamParam || '',
|
||||
})
|
||||
const [file, setFile] = useState(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -52,25 +160,16 @@ export default function PublicIssueSubmit() {
|
||||
|
||||
const updateForm = (field, value) => {
|
||||
setForm((f) => ({ ...f, [field]: value }))
|
||||
if (errors[field]) {
|
||||
setErrors((e) => ({ ...e, [field]: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFile = e.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile)
|
||||
}
|
||||
if (errors[field]) setErrors((e) => ({ ...e, [field]: '' }))
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {}
|
||||
if (!form.name.trim()) newErrors.name = 'Name is required'
|
||||
if (!form.email.trim()) newErrors.email = 'Email is required'
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) newErrors.email = 'Invalid email address'
|
||||
if (!form.title.trim()) newErrors.title = 'Title is required'
|
||||
if (!form.description.trim()) newErrors.description = 'Description is required'
|
||||
if (!form.name.trim()) newErrors.name = t('nameRequired')
|
||||
if (!form.email.trim()) newErrors.email = t('emailRequired')
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) newErrors.email = t('emailInvalid')
|
||||
if (!form.title.trim()) newErrors.title = t('titleRequired')
|
||||
if (!form.description.trim()) newErrors.description = t('descriptionRequired')
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
@@ -78,7 +177,6 @@ export default function PublicIssueSubmit() {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!validate() || submitting) return
|
||||
|
||||
try {
|
||||
setSubmitting(true)
|
||||
const formData = new FormData()
|
||||
@@ -90,19 +188,14 @@ export default function PublicIssueSubmit() {
|
||||
formData.append('priority', form.priority)
|
||||
formData.append('title', form.title)
|
||||
formData.append('description', form.description)
|
||||
if (form.team_id) {
|
||||
formData.append('team_id', form.team_id)
|
||||
}
|
||||
if (file) {
|
||||
formData.append('file', file)
|
||||
}
|
||||
|
||||
if (form.team_id) formData.append('team_id', form.team_id)
|
||||
if (file) formData.append('file', file)
|
||||
const result = await api.upload('/public/issues', formData)
|
||||
setTrackingToken(result.token)
|
||||
setSubmitted(true)
|
||||
} catch (err) {
|
||||
console.error('Submit error:', err)
|
||||
toast.error('Failed to submit issue. Please try again.')
|
||||
toast.error(t('submitFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -111,65 +204,40 @@ export default function PublicIssueSubmit() {
|
||||
if (submitted) {
|
||||
const trackingUrl = `${window.location.origin}/track/${trackingToken}`
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-lg mx-auto bg-surface rounded-xl border border-border p-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-emerald-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">Issue Submitted Successfully!</h1>
|
||||
<p className="text-sm text-text-tertiary mb-6">
|
||||
Thank you for submitting your issue. You can track its progress using the link below.
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('successTitle')}</h1>
|
||||
<p className="text-sm text-text-tertiary mb-6">{t('successMessage')}</p>
|
||||
|
||||
<div className="bg-surface-secondary rounded-lg p-4 mb-6">
|
||||
<label className="block text-xs font-semibold text-text-tertiary uppercase mb-2">Your Tracking Link</label>
|
||||
<label className="block text-xs font-semibold text-text-tertiary uppercase mb-2">{t('trackingLink')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={trackingUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(trackingUrl)
|
||||
toast.success('Copied to clipboard!')
|
||||
}}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
Copy
|
||||
<input type="text" value={trackingUrl} readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none" dir="ltr" />
|
||||
<button onClick={() => { navigator.clipboard.writeText(trackingUrl); toast.success(t('copied')) }}
|
||||
className="px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
|
||||
{t('copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={`/track/${trackingToken}`}
|
||||
className="block w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
Track Your Issue
|
||||
<a href={`/track/${trackingToken}`}
|
||||
className="block w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
|
||||
{t('trackIssue')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubmitted(false)
|
||||
setTrackingToken('')
|
||||
setForm({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category: 'Marketing',
|
||||
type: 'request',
|
||||
priority: 'medium',
|
||||
title: '',
|
||||
description: '',
|
||||
team_id: teamParam || '',
|
||||
})
|
||||
setFile(null)
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 border border-border text-text-secondary rounded-lg hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
Submit Another Issue
|
||||
<button onClick={() => {
|
||||
setSubmitted(false); setTrackingToken('')
|
||||
setForm({ name: '', email: '', phone: '', category: 'Marketing', type: 'request', priority: 'medium', title: '', description: '', team_id: teamParam || '' })
|
||||
setFile(null)
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 border border-border text-text-secondary rounded-lg hover:bg-surface-tertiary transition-colors">
|
||||
{t('submitAnother')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,194 +247,125 @@ export default function PublicIssueSubmit() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-lg mx-auto bg-surface rounded-xl border border-border p-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 bg-brand-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<AlertCircle className="w-6 h-6 text-brand-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">Submit an Issue</h1>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Report issues, request corrections, or make suggestions. We'll track your submission and keep you updated.
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('pageTitle')}</h1>
|
||||
<p className="text-sm text-text-tertiary">{t('pageSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Your Information</h2>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('yourInfo')}</h2>
|
||||
<div className="space-y-3">
|
||||
<FormInput
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm('name', e.target.value)}
|
||||
placeholder="Your full name"
|
||||
required
|
||||
error={errors.name}
|
||||
/>
|
||||
<FormInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => updateForm('email', e.target.value)}
|
||||
placeholder="your.email@example.com"
|
||||
required
|
||||
error={errors.email}
|
||||
/>
|
||||
<FormInput
|
||||
label="Phone (Optional)"
|
||||
value={form.phone}
|
||||
onChange={(e) => updateForm('phone', e.target.value)}
|
||||
placeholder="+966 5X XXX XXXX"
|
||||
/>
|
||||
<FormInput label={t('name')} value={form.name} onChange={(e) => updateForm('name', e.target.value)}
|
||||
placeholder={t('namePlaceholder')} required error={errors.name} />
|
||||
<FormInput label={t('email')} type="email" value={form.email} onChange={(e) => updateForm('email', e.target.value)}
|
||||
placeholder={t('emailPlaceholder')} required error={errors.email} />
|
||||
<FormInput label={t('phone')} value={form.phone} onChange={(e) => updateForm('phone', e.target.value)}
|
||||
placeholder={t('phonePlaceholder')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Selection */}
|
||||
{!teamParam && teams.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Which team should handle your issue?</h2>
|
||||
<select
|
||||
value={form.team_id}
|
||||
onChange={(e) => updateForm('team_id', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
<option value="">Select a team</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>{team.name}</option>
|
||||
))}
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('teamQuestion')}</h2>
|
||||
<select value={form.team_id} onChange={(e) => updateForm('team_id', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors">
|
||||
<option value="">{t('selectTeam')}</option>
|
||||
{teams.map((team) => <option key={team.id} value={team.id}>{team.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue Details */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Issue Details</h2>
|
||||
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('issueDetails')}</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Category <span className="text-red-500">*</span>
|
||||
{t('category')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.category}
|
||||
onChange={(e) => updateForm('category', e.target.value)}
|
||||
placeholder="e.g., Marketing, IT, Operations"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
/>
|
||||
<input type="text" value={form.category} onChange={(e) => updateForm('category', e.target.value)}
|
||||
placeholder={t('categoryPlaceholder')}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Type <span className="text-red-500">*</span>
|
||||
{t('type')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.type}
|
||||
onChange={(e) => updateForm('type', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
<select value={form.type} onChange={(e) => updateForm('type', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors">
|
||||
{TYPE_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
Priority <span className="text-red-500">*</span>
|
||||
{t('priority')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={(e) => updateForm('priority', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
<select value={form.priority} onChange={(e) => updateForm('priority', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors">
|
||||
{PRIORITY_OPTIONS.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<FormInput
|
||||
label="Title"
|
||||
value={form.title}
|
||||
onChange={(e) => updateForm('title', e.target.value)}
|
||||
placeholder="Brief summary of the issue"
|
||||
required
|
||||
error={errors.title}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
label="Description"
|
||||
type="textarea"
|
||||
value={form.description}
|
||||
<FormInput label={t('title')} value={form.title} onChange={(e) => updateForm('title', e.target.value)}
|
||||
placeholder={t('titlePlaceholder')} required error={errors.title} />
|
||||
<FormInput label={t('description')} type="textarea" value={form.description}
|
||||
onChange={(e) => updateForm('description', e.target.value)}
|
||||
placeholder="Provide detailed information about the issue..."
|
||||
rows={6}
|
||||
required
|
||||
error={errors.description}
|
||||
/>
|
||||
placeholder={t('descriptionPlaceholder')} rows={6} required error={errors.description} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Attachment (Optional)</h2>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('attachment')}</h2>
|
||||
<label className="block cursor-pointer">
|
||||
<input type="file" onChange={handleFileChange} className="hidden" />
|
||||
<input type="file" onChange={(e) => { if (e.target.files?.[0]) setFile(e.target.files[0]) }} className="hidden" />
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center hover:bg-surface-secondary/50 transition-colors">
|
||||
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<p className="text-sm text-text-primary font-medium">{file.name}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFile(null)
|
||||
}}
|
||||
className="p-1 hover:bg-surface-tertiary rounded"
|
||||
>
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); setFile(null) }} className="p-1 hover:bg-surface-tertiary rounded">
|
||||
<X className="w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-tertiary">Click to upload a file (screenshots, documents, etc.)</p>
|
||||
<p className="text-sm text-text-tertiary">{t('uploadPrompt')}</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{/* Submit */}
|
||||
<button type="submit" disabled={submitting}
|
||||
className="w-full px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2">
|
||||
{submitting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Submitting...
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{t('submitting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
Submit Issue
|
||||
{t('submit')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer Note */}
|
||||
<p className="text-xs text-text-tertiary text-center mt-4">
|
||||
You'll receive a tracking link to monitor the progress of your issue.
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary text-center mt-4">{t('footerNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,56 +1,126 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send } from 'lucide-react'
|
||||
import { AlertCircle, Clock, CheckCircle2, XCircle, MessageCircle, Upload, FileText, Send, Globe } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
|
||||
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
|
||||
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
|
||||
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
|
||||
declined: { label: 'Declined', bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle },
|
||||
// ─── Bilingual translations ────────────────────────────────────
|
||||
const T = {
|
||||
en: {
|
||||
description: 'Description',
|
||||
submitted: 'Submitted',
|
||||
lastUpdated: 'Last Updated',
|
||||
resolution: 'Resolution',
|
||||
declined: 'Declined',
|
||||
progressUpdates: 'Progress Updates',
|
||||
noUpdates: 'No updates yet. We\'ll post updates here as we work on your issue.',
|
||||
team: 'Team', you: 'You',
|
||||
attachments: 'Attachments',
|
||||
download: 'Download',
|
||||
addComment: 'Add a Comment',
|
||||
yourName: 'Your Name (Optional)',
|
||||
yourNamePlaceholder: 'Your name',
|
||||
message: 'Message',
|
||||
messagePlaceholder: 'Add additional information or ask a question...',
|
||||
sendComment: 'Send Comment',
|
||||
sending: 'Sending...',
|
||||
uploadFile: 'Upload File',
|
||||
uploading: 'Uploading...',
|
||||
bookmarkNote: 'Bookmark this page to check your issue status anytime.',
|
||||
notFoundTitle: 'Issue Not Found',
|
||||
notFoundMessage: 'The tracking link you used is invalid or the issue has been removed.',
|
||||
submitNew: 'Submit a New Issue',
|
||||
failedComment: 'Failed to add comment',
|
||||
failedUpload: 'Failed to upload file',
|
||||
priority: 'Priority',
|
||||
// Status
|
||||
new: 'New', acknowledged: 'Acknowledged', in_progress: 'In Progress', resolved: 'Resolved', declined_status: 'Declined',
|
||||
// Priority
|
||||
low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent',
|
||||
},
|
||||
ar: {
|
||||
description: 'الوصف',
|
||||
submitted: 'تم التقديم',
|
||||
lastUpdated: 'آخر تحديث',
|
||||
resolution: 'الحل',
|
||||
declined: 'مرفوض',
|
||||
progressUpdates: 'تحديثات التقدم',
|
||||
noUpdates: 'لا توجد تحديثات بعد. سننشر التحديثات هنا أثناء العمل على مشكلتك.',
|
||||
team: 'الفريق', you: 'أنت',
|
||||
attachments: 'المرفقات',
|
||||
download: 'تحميل',
|
||||
addComment: 'إضافة تعليق',
|
||||
yourName: 'اسمك (اختياري)',
|
||||
yourNamePlaceholder: 'اسمك',
|
||||
message: 'الرسالة',
|
||||
messagePlaceholder: 'أضف معلومات إضافية أو اطرح سؤالاً...',
|
||||
sendComment: 'إرسال التعليق',
|
||||
sending: 'جارٍ الإرسال...',
|
||||
uploadFile: 'رفع ملف',
|
||||
uploading: 'جارٍ الرفع...',
|
||||
bookmarkNote: 'احفظ هذه الصفحة للاطلاع على حالة مشكلتك في أي وقت.',
|
||||
notFoundTitle: 'المشكلة غير موجودة',
|
||||
notFoundMessage: 'رابط التتبع الذي استخدمته غير صالح أو تمت إزالة المشكلة.',
|
||||
submitNew: 'تقديم مشكلة جديدة',
|
||||
failedComment: 'فشل في إضافة التعليق',
|
||||
failedUpload: 'فشل في رفع الملف',
|
||||
priority: 'الأولوية',
|
||||
new: 'جديد', acknowledged: 'تم الاستلام', in_progress: 'قيد التنفيذ', resolved: 'تم الحل', declined_status: 'مرفوض',
|
||||
low: 'منخفضة', medium: 'متوسطة', high: 'عالية', urgent: 'عاجلة',
|
||||
},
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: 'Low', color: 'text-gray-700' },
|
||||
medium: { label: 'Medium', color: 'text-blue-700' },
|
||||
high: { label: 'High', color: 'text-orange-700' },
|
||||
urgent: { label: 'Urgent', color: 'text-red-700' },
|
||||
function detectLang() {
|
||||
const nav = navigator.language || navigator.userLanguage || ''
|
||||
return nav.startsWith('ar') ? 'ar' : 'en'
|
||||
}
|
||||
|
||||
function LangToggle({ lang, setLang }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
|
||||
className="fixed top-4 end-4 z-50 flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border rounded-full shadow-sm hover:bg-surface-secondary transition-colors text-sm font-medium text-text-primary"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{lang === 'en' ? 'العربية' : 'English'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PublicIssueTracker() {
|
||||
const { token } = useParams()
|
||||
const toast = useToast()
|
||||
const [lang, setLang] = useState(detectLang)
|
||||
const t = (key) => T[lang]?.[key] || T.en[key] || key
|
||||
const dir = lang === 'ar' ? 'rtl' : 'ltr'
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = dir
|
||||
document.documentElement.lang = lang
|
||||
return () => { document.documentElement.dir = 'ltr'; document.documentElement.lang = 'en' }
|
||||
}, [lang, dir])
|
||||
|
||||
const [issue, setIssue] = useState(null)
|
||||
const [updates, setUpdates] = useState([])
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// Comment form
|
||||
const [commentName, setCommentName] = useState('')
|
||||
const [commentMessage, setCommentMessage] = useState('')
|
||||
const [submittingComment, setSubmittingComment] = useState(false)
|
||||
|
||||
// File upload
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadIssue()
|
||||
}, [token])
|
||||
useEffect(() => { loadIssue() }, [token])
|
||||
|
||||
const loadIssue = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true); setError(null)
|
||||
const data = await api.get(`/public/issues/${token}`)
|
||||
setIssue(data.issue)
|
||||
setUpdates(data.updates || [])
|
||||
setAttachments(data.attachments || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load issue:', err)
|
||||
setError(err.response?.status === 404 ? 'Issue not found' : 'Failed to load issue')
|
||||
setError(err.response?.status === 404 ? 'notFound' : 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -59,27 +129,18 @@ export default function PublicIssueTracker() {
|
||||
const handleAddComment = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!commentMessage.trim() || submittingComment) return
|
||||
|
||||
try {
|
||||
setSubmittingComment(true)
|
||||
await api.post(`/public/issues/${token}/comment`, {
|
||||
name: commentName.trim() || 'Anonymous',
|
||||
message: commentMessage,
|
||||
})
|
||||
await api.post(`/public/issues/${token}/comment`, { name: commentName.trim() || 'Anonymous', message: commentMessage })
|
||||
setCommentMessage('')
|
||||
await loadIssue()
|
||||
} catch (err) {
|
||||
console.error('Failed to add comment:', err)
|
||||
toast.error('Failed to add comment')
|
||||
} finally {
|
||||
setSubmittingComment(false)
|
||||
}
|
||||
} catch { toast.error(t('failedComment')) }
|
||||
finally { setSubmittingComment(false) }
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
setUploadingFile(true)
|
||||
const formData = new FormData()
|
||||
@@ -87,52 +148,52 @@ export default function PublicIssueTracker() {
|
||||
formData.append('name', commentName.trim() || 'Anonymous')
|
||||
await api.upload(`/public/issues/${token}/attachments`, formData)
|
||||
await loadIssue()
|
||||
e.target.value = '' // Reset input
|
||||
} catch (err) {
|
||||
console.error('Failed to upload file:', err)
|
||||
toast.error('Failed to upload file')
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
}
|
||||
e.target.value = ''
|
||||
} catch { toast.error(t('failedUpload')) }
|
||||
finally { setUploadingFile(false) }
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const dateFmt = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
return new Date(dateStr).toLocaleDateString(lang === 'ar' ? 'ar-SA' : 'en-US', { month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const formatDateTime = (dateStr) => {
|
||||
const dateTimeFmt = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
return new Date(dateStr).toLocaleString(lang === 'ar' ? 'ar-SA' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
const fileSize = (bytes) => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
new: { label: t('new'), bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500', icon: AlertCircle },
|
||||
acknowledged: { label: t('acknowledged'), bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500', icon: CheckCircle2 },
|
||||
in_progress: { label: t('in_progress'), bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500', icon: Clock },
|
||||
resolved: { label: t('resolved'), bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500', icon: CheckCircle2 },
|
||||
declined: { label: t('declined_status'), bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500', icon: XCircle },
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
low: { label: t('low'), color: 'text-gray-700' },
|
||||
medium: { label: t('medium'), color: 'text-blue-700' },
|
||||
high: { label: t('high'), color: 'text-orange-700' },
|
||||
urgent: { label: t('urgent'), color: 'text-red-700' },
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-pulse">
|
||||
<div className="bg-surface rounded-xl border border-border p-6 space-y-4">
|
||||
<div className="h-5 bg-surface-tertiary rounded w-24"></div>
|
||||
<div className="h-7 bg-surface-tertiary rounded w-3/4"></div>
|
||||
<div className="h-4 bg-surface-tertiary rounded w-full"></div>
|
||||
<div className="h-4 bg-surface-tertiary rounded w-2/3"></div>
|
||||
</div>
|
||||
<div className="bg-surface rounded-xl border border-border p-6 space-y-4">
|
||||
<div className="h-5 bg-surface-tertiary rounded w-40"></div>
|
||||
<div className="h-20 bg-surface-tertiary rounded"></div>
|
||||
<div className="h-5 bg-surface-tertiary rounded w-24" />
|
||||
<div className="h-7 bg-surface-tertiary rounded w-3/4" />
|
||||
<div className="h-4 bg-surface-tertiary rounded w-full" />
|
||||
<div className="h-4 bg-surface-tertiary rounded w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,20 +202,16 @@ export default function PublicIssueTracker() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4 ">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-md mx-auto bg-surface rounded-xl border border-border p-6 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<XCircle className="w-10 h-10 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">Issue Not Found</h1>
|
||||
<p className="text-sm text-text-tertiary mb-6">
|
||||
The tracking link you used is invalid or the issue has been removed.
|
||||
</p>
|
||||
<a
|
||||
href="/submit-issue"
|
||||
className="inline-block px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
Submit a New Issue
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">{t('notFoundTitle')}</h1>
|
||||
<p className="text-sm text-text-tertiary mb-6">{t('notFoundMessage')}</p>
|
||||
<a href="/submit-issue" className="inline-block px-4 py-2 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light transition-colors">
|
||||
{t('submitNew')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,22 +221,22 @@ export default function PublicIssueTracker() {
|
||||
if (!issue) return null
|
||||
|
||||
const statusConfig = STATUS_CONFIG[issue.status] || STATUS_CONFIG.new
|
||||
const StatusIcon = statusConfig.icon
|
||||
const priorityConfig = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4 ">
|
||||
<div className="min-h-screen bg-surface-secondary py-8 px-4" dir={dir}>
|
||||
<LangToggle lang={lang} setLang={setLang} />
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 ${statusConfig.bg} ${statusConfig.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`} />
|
||||
{statusConfig.label}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${priorityConfig.color}`}>
|
||||
{priorityConfig.label} Priority
|
||||
{priorityConfig.label} {t('priority')}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">•</span>
|
||||
<span className="text-xs text-text-tertiary capitalize">{issue.type}</span>
|
||||
@@ -188,18 +245,18 @@ export default function PublicIssueTracker() {
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">Description</h2>
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-2">{t('description')}</h2>
|
||||
<p className="text-sm text-text-tertiary whitespace-pre-wrap">{issue.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm pt-4 border-t border-border">
|
||||
<div>
|
||||
<span className="text-text-tertiary">Submitted:</span>
|
||||
<span className="text-text-primary font-medium ml-2">{formatDate(issue.created_at)}</span>
|
||||
<span className="text-text-tertiary">{t('submitted')}:</span>
|
||||
<span className="text-text-primary font-medium ms-2">{dateFmt(issue.created_at)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-tertiary">Last Updated:</span>
|
||||
<span className="text-text-primary font-medium ml-2">{formatDate(issue.updated_at)}</span>
|
||||
<span className="text-text-tertiary">{t('lastUpdated')}:</span>
|
||||
<span className="text-text-primary font-medium ms-2">{dateFmt(issue.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,21 +265,19 @@ export default function PublicIssueTracker() {
|
||||
{(issue.status === 'resolved' || issue.status === 'declined') && issue.resolution_summary && (
|
||||
<div className={`rounded-2xl shadow-sm p-6 mb-6 ${issue.status === 'resolved' ? 'bg-emerald-50 border-2 border-emerald-200' : 'bg-gray-50 border-2 border-gray-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{issue.status === 'resolved' ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />
|
||||
)}
|
||||
{issue.status === 'resolved'
|
||||
? <CheckCircle2 className="w-6 h-6 text-emerald-600 shrink-0 mt-1" />
|
||||
: <XCircle className="w-6 h-6 text-gray-600 shrink-0 mt-1" />}
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-lg font-bold mb-2 ${issue.status === 'resolved' ? 'text-emerald-900' : 'text-gray-900'}`}>
|
||||
{issue.status === 'resolved' ? 'Resolution' : 'Declined'}
|
||||
{issue.status === 'resolved' ? t('resolution') : t('declined')}
|
||||
</h2>
|
||||
<p className={`${issue.status === 'resolved' ? 'text-emerald-800' : 'text-gray-800'} whitespace-pre-wrap`}>
|
||||
{issue.resolution_summary}
|
||||
</p>
|
||||
{issue.resolved_at && (
|
||||
<p className={`text-sm mt-2 ${issue.status === 'resolved' ? 'text-emerald-600' : 'text-gray-600'}`}>
|
||||
{formatDate(issue.resolved_at)}
|
||||
{dateFmt(issue.resolved_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -234,26 +289,25 @@ export default function PublicIssueTracker() {
|
||||
<div className="bg-surface rounded-2xl shadow-sm p-8 mb-6">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
|
||||
<MessageCircle className="w-6 h-6" />
|
||||
Progress Updates
|
||||
{t('progressUpdates')}
|
||||
</h2>
|
||||
|
||||
{updates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Clock className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary">No updates yet. We'll post updates here as we work on your issue.</p>
|
||||
<p className="text-text-secondary">{t('noUpdates')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{updates.map((update, idx) => (
|
||||
<div key={update.Id || update.id || idx} className="border-l-4 border-brand-primary pl-4 py-2">
|
||||
<div key={update.Id || update.id || idx} className="border-s-4 border-brand-primary ps-4 py-2">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-text-primary">{update.author_name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${update.author_type === 'staff' ? 'bg-purple-100 text-purple-700' : 'bg-gray-200 text-gray-700'}`}>
|
||||
{update.author_type === 'staff' ? 'Team' : 'You'}
|
||||
{update.author_type === 'staff' ? t('team') : t('you')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-tertiary">{formatDateTime(update.created_at)}</span>
|
||||
<span className="text-sm text-text-tertiary">{dateTimeFmt(update.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-text-secondary whitespace-pre-wrap">{update.message}</p>
|
||||
</div>
|
||||
@@ -267,7 +321,7 @@ export default function PublicIssueTracker() {
|
||||
<div className="bg-surface rounded-2xl shadow-sm p-8 mb-6">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
|
||||
<FileText className="w-6 h-6" />
|
||||
Attachments
|
||||
{t('attachments')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{attachments.map((att) => (
|
||||
@@ -276,18 +330,12 @@ export default function PublicIssueTracker() {
|
||||
<FileText className="w-5 h-5 text-text-tertiary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{att.original_name}</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{formatFileSize(att.size)} • {att.uploaded_by}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary">{fileSize(att.size)} • {att.uploaded_by}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/uploads/${att.filename}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-brand-primary hover:underline ml-2"
|
||||
>
|
||||
Download
|
||||
<a href={`/api/uploads/${att.filename}`} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-brand-primary hover:underline ms-2">
|
||||
{t('download')}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
@@ -295,55 +343,39 @@ export default function PublicIssueTracker() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Comment Section */}
|
||||
{/* Add Comment */}
|
||||
{issue.status !== 'resolved' && issue.status !== 'declined' && (
|
||||
<div className="bg-surface rounded-2xl shadow-sm p-8">
|
||||
<h2 className="text-xl font-bold text-text-primary mb-6 flex items-center gap-2">
|
||||
<MessageCircle className="w-6 h-6" />
|
||||
Add a Comment
|
||||
{t('addComment')}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleAddComment} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">Your Name (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={commentName}
|
||||
onChange={(e) => setCommentName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">{t('yourName')}</label>
|
||||
<input type="text" value={commentName} onChange={(e) => setCommentName(e.target.value)}
|
||||
placeholder={t('yourNamePlaceholder')}
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Message <span className="text-red-500">*</span>
|
||||
{t('message')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={commentMessage}
|
||||
onChange={(e) => setCommentMessage(e.target.value)}
|
||||
placeholder="Add additional information or ask a question..."
|
||||
rows={4}
|
||||
required
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
/>
|
||||
<textarea value={commentMessage} onChange={(e) => setCommentMessage(e.target.value)}
|
||||
placeholder={t('messagePlaceholder')} rows={4} required
|
||||
className="w-full px-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!commentMessage.trim() || submittingComment}
|
||||
className="px-6 py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<button type="submit" disabled={!commentMessage.trim() || submittingComment}
|
||||
className="px-6 py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
{submittingComment ? 'Sending...' : 'Send Comment'}
|
||||
{submittingComment ? t('sending') : t('sendComment')}
|
||||
</button>
|
||||
|
||||
<label className="cursor-pointer">
|
||||
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
|
||||
<div className="px-6 py-3 bg-surface-secondary text-text-primary rounded-lg font-medium hover:bg-surface-tertiary transition-colors flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
{uploadingFile ? 'Uploading...' : 'Upload File'}
|
||||
{uploadingFile ? t('uploading') : t('uploadFile')}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -353,9 +385,7 @@ export default function PublicIssueTracker() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="text-center mt-8">
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Bookmark this page to check your issue status anytime.
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary">{t('bookmarkNote')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user