diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index e346becf8..108ba8f68 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -49,5 +49,6 @@ export { LazySubscription, LazyDistribution, LazyEducation, + LazySupport, } from './lazy-component'; export type { LazyComponentProps, LazyErrorFallbackProps, LazyErrorBoundaryProps } from './lazy-component'; diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts index 39ca052d6..e57e79d73 100644 --- a/apps/web/src/components/ui/lazy-component/index.ts +++ b/apps/web/src/components/ui/lazy-component/index.ts @@ -52,4 +52,5 @@ export { LazySubscription, LazyDistribution, LazyEducation, + LazySupport, } from './lazyExports'; diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts index 9b1e554bf..8ac9e849f 100644 --- a/apps/web/src/components/ui/lazy-component/lazyExports.ts +++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts @@ -339,3 +339,12 @@ export const LazyEducation = createLazyComponent( undefined, 'Education', ); +// v0.13.5 TASK-MKT-004: Support page +export const LazySupport = createLazyComponent( + () => + import('@/features/support/pages/SupportPage').then((m) => ({ + default: m.SupportPage, + })), + undefined, + 'Support', +); diff --git a/apps/web/src/features/support/pages/SupportPage.test.tsx b/apps/web/src/features/support/pages/SupportPage.test.tsx new file mode 100644 index 000000000..1e5df9b52 --- /dev/null +++ b/apps/web/src/features/support/pages/SupportPage.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SupportPage } from './SupportPage'; + +// Mock dependencies +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback || key, + }), +})); + +vi.mock('@/features/auth/hooks/useUser', () => ({ + useUser: () => ({ data: { email: 'test@example.com', username: 'testuser' } }), +})); + +vi.mock('@/features/auth/store/authStore', () => ({ + useAuthStore: () => ({ isAuthenticated: true }), +})); + +vi.mock('@/components/layout/DashboardLayout', () => ({ + DashboardLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const mockPost = vi.fn().mockResolvedValue({ data: { ticket_id: 'ticket-123' } }); +vi.mock('@/services/api/client', () => ({ + apiClient: { post: (...args: unknown[]) => mockPost(...args) }, +})); + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + +describe('SupportPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPost.mockResolvedValue({ data: { ticket_id: 'ticket-123' } }); + }); + + it('should render the support form', () => { + renderWithProviders(); + expect(screen.getByText('Support')).toBeDefined(); + expect(screen.getByLabelText('Email address')).toBeDefined(); + expect(screen.getByLabelText('Subject')).toBeDefined(); + expect(screen.getByLabelText('Message')).toBeDefined(); + }); + + it('should pre-fill email from authenticated user', () => { + renderWithProviders(); + const emailInput = screen.getByLabelText('Email address') as HTMLInputElement; + expect(emailInput.value).toBe('test@example.com'); + }); + + it('should disable submit when form is incomplete', () => { + renderWithProviders(); + const submitBtn = screen.getByRole('button', { name: /send message/i }); + expect(submitBtn).toBeDisabled(); + }); + + it('should enable submit when form is complete', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText('Subject'), 'Payment issue'); + await user.type(screen.getByLabelText('Message'), 'I have a problem with my recent purchase.'); + + const submitBtn = screen.getByRole('button', { name: /send message/i }); + expect(submitBtn).not.toBeDisabled(); + }); + + it('should submit the form and show success message', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText('Subject'), 'Payment issue'); + await user.type(screen.getByLabelText('Message'), 'I have a problem with my recent purchase.'); + await user.click(screen.getByRole('button', { name: /send message/i })); + + await waitFor(() => { + expect(screen.getByText('Message sent!')).toBeDefined(); + }); + + expect(mockPost).toHaveBeenCalledWith('/support/tickets', expect.objectContaining({ + email: 'test@example.com', + subject: 'Payment issue', + category: 'general', + })); + }); + + it('should show error on submission failure', async () => { + mockPost.mockRejectedValueOnce(new Error('Network error')); + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText('Subject'), 'Payment issue'); + await user.type(screen.getByLabelText('Message'), 'I have a problem with my recent purchase.'); + await user.click(screen.getByRole('button', { name: /send message/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeDefined(); + }); + }); + + it('should render quick help links', () => { + renderWithProviders(); + expect(screen.getByText('Quick help')).toBeDefined(); + expect(screen.getByText('Payment issues')).toBeDefined(); + expect(screen.getByText('Account settings')).toBeDefined(); + }); +}); diff --git a/apps/web/src/features/support/pages/SupportPage.tsx b/apps/web/src/features/support/pages/SupportPage.tsx new file mode 100644 index 000000000..d4190dca4 --- /dev/null +++ b/apps/web/src/features/support/pages/SupportPage.tsx @@ -0,0 +1,225 @@ +/** + * SupportPage — Accessible contact/support form + * v0.13.5 TASK-MKT-004: Page support accessible + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DashboardLayout } from '@/components/layout/DashboardLayout'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { useUser } from '@/features/auth/hooks/useUser'; +import { apiClient } from '@/services/api/client'; +import { Mail, CheckCircle2, AlertCircle, Send, HelpCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const CATEGORY_OPTIONS = [ + { value: 'general', label: 'General' }, + { value: 'payment', label: 'Payment' }, + { value: 'account', label: 'Account' }, + { value: 'bug', label: 'Bug Report' }, +]; + +type SubmitState = 'idle' | 'submitting' | 'success' | 'error'; + +export function SupportPage() { + const { t } = useTranslation(); + const { data: user } = useUser(); + const [email, setEmail] = useState(user?.email || ''); + const [subject, setSubject] = useState(''); + const [message, setMessage] = useState(''); + const [category, setCategory] = useState('general'); + const [submitState, setSubmitState] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitState('submitting'); + setErrorMessage(''); + + try { + await apiClient.post('/support/tickets', { + email: email.trim(), + subject: subject.trim(), + message: message.trim(), + category, + }); + setSubmitState('success'); + setSubject(''); + setMessage(''); + } catch (err: unknown) { + setSubmitState('error'); + const msg = err instanceof Error ? err.message : 'An error occurred'; + setErrorMessage(msg); + } + }; + + const isValid = email.includes('@') && subject.trim().length >= 3 && message.trim().length >= 10; + + return ( + +
+
+

+ {t('support.title', 'Support')} +

+

+ {t('support.description', "Need help? Send us a message and we'll get back to you within 48 hours.")} +

+
+ + {submitState === 'success' ? ( +
+ +

+ {t('support.success.title', 'Message sent!')} +

+

+ {t('support.success.description', "We've received your message and will respond to your email within 48 hours.")} +

+ +
+ ) : ( +
+ {/* Email */} +
+ +
+ + setEmail(e.target.value)} + placeholder="your@email.com" + className="pl-10" + required + aria-describedby="email-hint" + /> +
+

+ {t('support.form.emailHint', "We'll respond to this address")} +

+
+ + {/* Category */} +
+ + setSubject(e.target.value)} + placeholder={t('support.form.subjectPlaceholder', 'Brief description of your issue') as string} + minLength={3} + maxLength={500} + required + /> +
+ + {/* Message */} +
+ +