Skip to content

Commit 6410ebb

Browse files
committed
feat: add contact form modal with Static Forms integration
- Replace all mailto: links with contact modal on "Get in touch" buttons - Add ContactModal component with name, email, message fields - Add ContactModalContext for global modal state management - Add TextArea component for multi-line input - Add translations for contact modal in all 5 languages (en, ja, it, es, fr) - Integrate Static Forms API for form submission - Keep mailto: links in footer and contact info sections unchanged
1 parent 70b835a commit 6410ebb

29 files changed

+388
-25
lines changed

public/locales/en/common.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@
110110
"keep_current": "Keep {{language}}",
111111
"close": "Close",
112112
"aria_label": "Language suggestion"
113+
},
114+
"contactModal": {
115+
"title": "Get in touch",
116+
"description": "Send us a message and we'll get back to you shortly.",
117+
"name": "Name",
118+
"namePlaceholder": "Your name",
119+
"email": "Email",
120+
"emailPlaceholder": "your@email.com",
121+
"message": "Message",
122+
"messagePlaceholder": "How can we help?",
123+
"send": "Send message",
124+
"sending": "Sending...",
125+
"cancel": "Cancel",
126+
"success": "Message sent! We'll get back to you soon.",
127+
"error": "Failed to send message. Please try again."
113128
}
114129
}
115130

public/locales/es/common.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,20 @@
110110
"keep_current": "Mantener {{language}}",
111111
"close": "Cerrar",
112112
"aria_label": "Sugerencia de idioma"
113+
},
114+
"contactModal": {
115+
"title": "Contáctanos",
116+
"description": "Envíanos un mensaje y te responderemos pronto.",
117+
"name": "Nombre",
118+
"namePlaceholder": "Tu nombre",
119+
"email": "Email",
120+
"emailPlaceholder": "tu@email.com",
121+
"message": "Mensaje",
122+
"messagePlaceholder": "¿Cómo podemos ayudarte?",
123+
"send": "Enviar mensaje",
124+
"sending": "Enviando...",
125+
"cancel": "Cancelar",
126+
"success": "¡Mensaje enviado! Te responderemos pronto.",
127+
"error": "Error al enviar. Por favor, inténtalo de nuevo."
113128
}
114129
}

public/locales/fr/common.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,20 @@
110110
"keep_current": "Garder {{language}}",
111111
"close": "Fermer",
112112
"aria_label": "Suggestion de langue"
113+
},
114+
"contactModal": {
115+
"title": "Nous contacter",
116+
"description": "Envoyez-nous un message et nous vous répondrons rapidement.",
117+
"name": "Nom",
118+
"namePlaceholder": "Votre nom",
119+
"email": "Email",
120+
"emailPlaceholder": "votre@email.com",
121+
"message": "Message",
122+
"messagePlaceholder": "Comment pouvons-nous vous aider ?",
123+
"send": "Envoyer le message",
124+
"sending": "Envoi en cours...",
125+
"cancel": "Annuler",
126+
"success": "Message envoyé ! Nous vous répondrons bientôt.",
127+
"error": "Échec de l'envoi. Veuillez réessayer."
113128
}
114129
}

public/locales/it/common.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,20 @@
110110
"keep_current": "Mantieni {{language}}",
111111
"close": "Chiudi",
112112
"aria_label": "Suggerimento lingua"
113+
},
114+
"contactModal": {
115+
"title": "Contattaci",
116+
"description": "Inviaci un messaggio e ti risponderemo al più presto.",
117+
"name": "Nome",
118+
"namePlaceholder": "Il tuo nome",
119+
"email": "Email",
120+
"emailPlaceholder": "tua@email.com",
121+
"message": "Messaggio",
122+
"messagePlaceholder": "Come possiamo aiutarti?",
123+
"send": "Invia messaggio",
124+
"sending": "Invio in corso...",
125+
"cancel": "Annulla",
126+
"success": "Messaggio inviato! Ti risponderemo presto.",
127+
"error": "Invio non riuscito. Riprova."
113128
}
114129
}

public/locales/ja/common.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,5 +110,20 @@
110110
"keep_current": "{{language}}のまま",
111111
"close": "閉じる",
112112
"aria_label": "言語の提案"
113+
},
114+
"contactModal": {
115+
"title": "お問い合わせ",
116+
"description": "メッセージをお送りください。折り返しご連絡いたします。",
117+
"name": "お名前",
118+
"namePlaceholder": "お名前を入力",
119+
"email": "メールアドレス",
120+
"emailPlaceholder": "your@email.com",
121+
"message": "メッセージ",
122+
"messagePlaceholder": "ご質問内容をお書きください",
123+
"send": "送信する",
124+
"sending": "送信中...",
125+
"cancel": "キャンセル",
126+
"success": "送信完了!折り返しご連絡いたします。",
127+
"error": "送信に失敗しました。もう一度お試しください。"
113128
}
114129
}

src/components/ContactModal.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import ModalShell from './modals/ModalShell'
4+
import { FormField, TextInput, TextArea } from './ui'
5+
import { useToast } from '../contexts/ToastContext'
6+
import { useContactModal } from '../contexts/ContactModalContext'
7+
import { INPUT_BASE_CLASSES } from './ui/inputStyles'
8+
9+
const STATIC_FORMS_API_KEY = 'sf_h86a9a9760cijki4amg2ikb7'
10+
11+
export default function ContactModal() {
12+
const { t } = useTranslation('common')
13+
const { isContactModalOpen, closeContactModal } = useContactModal()
14+
const { showSuccess, showError } = useToast()
15+
16+
const [name, setName] = useState('')
17+
const [email, setEmail] = useState('')
18+
const [message, setMessage] = useState('')
19+
const [isSubmitting, setIsSubmitting] = useState(false)
20+
21+
const resetForm = () => {
22+
setName('')
23+
setEmail('')
24+
setMessage('')
25+
}
26+
27+
const handleClose = () => {
28+
resetForm()
29+
closeContactModal()
30+
}
31+
32+
const handleSubmit = async () => {
33+
if (!name.trim() || !email.trim() || !message.trim()) {
34+
return
35+
}
36+
37+
setIsSubmitting(true)
38+
39+
try {
40+
const response = await fetch('https://api.staticforms.dev/submit', {
41+
method: 'POST',
42+
headers: {
43+
'Content-Type': 'application/json',
44+
},
45+
body: JSON.stringify({
46+
apiKey: STATIC_FORMS_API_KEY,
47+
subject: 'Contact from Folio website',
48+
name: name.trim(),
49+
email: email.trim(),
50+
message: message.trim(),
51+
replyTo: email.trim(),
52+
}),
53+
})
54+
55+
if (response.ok) {
56+
showSuccess(t('contactModal.success', 'Message sent! We\'ll get back to you soon.'))
57+
handleClose()
58+
} else {
59+
showError(t('contactModal.error', 'Failed to send message. Please try again.'))
60+
}
61+
} catch {
62+
showError(t('contactModal.error', 'Failed to send message. Please try again.'))
63+
} finally {
64+
setIsSubmitting(false)
65+
}
66+
}
67+
68+
const isFormValid = name.trim() && email.trim() && message.trim()
69+
70+
return (
71+
<ModalShell
72+
isOpen={isContactModalOpen}
73+
onClose={handleClose}
74+
title={t('contactModal.title', 'Get in touch')}
75+
description={t('contactModal.description', 'Send us a message and we\'ll get back to you shortly.')}
76+
size="small"
77+
footer={{
78+
secondary: {
79+
label: t('contactModal.cancel', 'Cancel'),
80+
onClick: handleClose
81+
},
82+
primary: {
83+
label: isSubmitting
84+
? t('contactModal.sending', 'Sending...')
85+
: t('contactModal.send', 'Send message'),
86+
onClick: handleSubmit,
87+
disabled: !isFormValid || isSubmitting,
88+
},
89+
}}
90+
>
91+
<div className="flex flex-col gap-4 items-start w-full">
92+
<FormField label={t('contactModal.name', 'Name')} required>
93+
<TextInput
94+
value={name}
95+
onChange={setName}
96+
placeholder={t('contactModal.namePlaceholder', 'Your name')}
97+
autoComplete="name"
98+
name="contact-name"
99+
className={INPUT_BASE_CLASSES}
100+
disabled={isSubmitting}
101+
/>
102+
</FormField>
103+
104+
<FormField label={t('contactModal.email', 'Email')} required>
105+
<TextInput
106+
value={email}
107+
onChange={setEmail}
108+
placeholder={t('contactModal.emailPlaceholder', 'your@email.com')}
109+
autoComplete="email"
110+
name="contact-email"
111+
inputMode="email"
112+
className={INPUT_BASE_CLASSES}
113+
disabled={isSubmitting}
114+
/>
115+
</FormField>
116+
117+
<FormField label={t('contactModal.message', 'Message')} required>
118+
<TextArea
119+
value={message}
120+
onChange={setMessage}
121+
placeholder={t('contactModal.messagePlaceholder', 'How can we help?')}
122+
name="contact-message"
123+
rows={4}
124+
disabled={isSubmitting}
125+
/>
126+
</FormField>
127+
</div>
128+
</ModalShell>
129+
)
130+
}

src/components/ui/TextArea.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useState } from 'react'
2+
import { INPUT_BASE_FOCUS_STYLES, INPUT_FOCUS_CLASSES } from './inputStyles'
3+
4+
interface TextAreaProps extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'onBlur' | 'value' | 'defaultValue'> {
5+
defaultValue?: string
6+
value?: string
7+
onChange?: (value: string) => void
8+
onBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void
9+
error?: boolean
10+
}
11+
12+
export default function TextArea({
13+
defaultValue = '',
14+
value: controlledValue,
15+
placeholder,
16+
className = '',
17+
onChange,
18+
onBlur,
19+
disabled = false,
20+
error = false,
21+
rows = 4,
22+
...restProps
23+
}: TextAreaProps) {
24+
const [internalValue, setInternalValue] = useState(defaultValue)
25+
const isControlled = controlledValue !== undefined
26+
const value = isControlled ? controlledValue : internalValue
27+
28+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
29+
const newValue = e.target.value
30+
if (!isControlled) {
31+
setInternalValue(newValue)
32+
}
33+
onChange?.(newValue)
34+
}
35+
36+
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
37+
if (!isControlled && value.trim() === '' && defaultValue) {
38+
setInternalValue(defaultValue)
39+
}
40+
onBlur?.(e)
41+
}
42+
43+
const focusStyles = error ? INPUT_FOCUS_CLASSES.error : INPUT_FOCUS_CLASSES.default
44+
45+
return (
46+
<textarea
47+
value={value}
48+
onChange={handleChange}
49+
onBlur={handleBlur}
50+
placeholder={placeholder}
51+
className={`bg-white border border-[#e5e5e5] border-solid box-border flex gap-1 items-start px-3 py-2 rounded-md w-full text-sm leading-5 text-[#0a0a0a] outline-none resize-none ${INPUT_BASE_FOCUS_STYLES} ${focusStyles} ${className}`}
52+
disabled={disabled}
53+
aria-invalid={error ? 'true' : 'false'}
54+
rows={rows}
55+
{...restProps}
56+
/>
57+
)
58+
}

src/components/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { default as Switch } from './Switch'
22
export { default as TextInput } from './TextInput'
3+
export { default as TextArea } from './TextArea'
34
export { default as DateInput } from './DateInput'
45
export { default as Button } from './Button'
56
export { useFocusTrap } from './useFocusTrap'
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
2+
3+
interface ContactModalContextType {
4+
isContactModalOpen: boolean
5+
openContactModal: () => void
6+
closeContactModal: () => void
7+
}
8+
9+
const ContactModalContext = createContext<ContactModalContextType | undefined>(undefined)
10+
11+
interface ContactModalProviderProps {
12+
children: ReactNode
13+
}
14+
15+
export function ContactModalProvider({ children }: ContactModalProviderProps) {
16+
const [isContactModalOpen, setIsContactModalOpen] = useState(false)
17+
18+
const openContactModal = useCallback(() => {
19+
setIsContactModalOpen(true)
20+
}, [])
21+
22+
const closeContactModal = useCallback(() => {
23+
setIsContactModalOpen(false)
24+
}, [])
25+
26+
return (
27+
<ContactModalContext.Provider value={{ isContactModalOpen, openContactModal, closeContactModal }}>
28+
{children}
29+
</ContactModalContext.Provider>
30+
)
31+
}
32+
33+
// eslint-disable-next-line react-refresh/only-export-components
34+
export function useContactModal() {
35+
const context = useContext(ContactModalContext)
36+
if (context === undefined) {
37+
throw new Error('useContactModal must be used within a ContactModalProvider')
38+
}
39+
return context
40+
}

src/main.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import './index.css'
55
import './i18n' // Initialize i18n
66
import { ErrorBoundary } from './components/ErrorBoundary'
77
import { ToastProvider } from './contexts/ToastContext'
8+
import { ContactModalProvider } from './contexts/ContactModalContext'
9+
import ContactModal from './components/ContactModal'
810

911
// GitHub Pages SPA routing support (rafgraph/spa-github-pages style).
1012
// Converts `?/path` back into `/path` before React Router mounts.
@@ -59,7 +61,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
5961
<ErrorBoundary>
6062
<Suspense fallback={<div className="min-h-screen" />}>
6163
<ToastProvider>
62-
<App />
64+
<ContactModalProvider>
65+
<App />
66+
<ContactModal />
67+
</ContactModalProvider>
6368
</ToastProvider>
6469
</Suspense>
6570
</ErrorBoundary>

0 commit comments

Comments
 (0)