diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example index 30a2abf58..4a7dd08dd 100644 --- a/apps/frontend/.env.example +++ b/apps/frontend/.env.example @@ -23,4 +23,11 @@ REACT_APP_ENABLE_SERVICE_WORKER=false REACT_APP_SIMULATE_TX=false REACT_APP_ESTIMATOR_URI=https://simulator.sovryn.app -REACT_APP_DATADOG_CLIENT_TOKEN= \ No newline at end of file +REACT_APP_DATADOG_CLIENT_TOKEN= + +# Support form +REACT_APP_TURNSTILE_SITE_KEY= + +# Netlify Function variables (configure in Netlify environment settings) +TURNSTILE_SECRET_KEY= +DISCORD_SUPPORT_WEBHOOK_URL= diff --git a/apps/frontend/netlify.toml b/apps/frontend/netlify.toml index 36630dea2..53e643142 100644 --- a/apps/frontend/netlify.toml +++ b/apps/frontend/netlify.toml @@ -2,3 +2,6 @@ base = "apps/frontend/" publish = "build" command = "yarn build" + +[functions] + directory = "apps/frontend/netlify/functions" diff --git a/apps/frontend/netlify/functions/support-feedback.js b/apps/frontend/netlify/functions/support-feedback.js new file mode 100644 index 000000000..66cc0e335 --- /dev/null +++ b/apps/frontend/netlify/functions/support-feedback.js @@ -0,0 +1,197 @@ +const TURNSTILE_VERIFY_URL = + 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +const MAX_NAME_LENGTH = 80; +const MAX_EMAIL_LENGTH = 254; +const MAX_MESSAGE_LENGTH = 2000; +const MAX_DISCORD_DESCRIPTION_LENGTH = 3500; + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', +}; + +const JSON_HEADERS = { + ...CORS_HEADERS, + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', +}; + +const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +const jsonResponse = (statusCode, payload) => ({ + statusCode, + headers: JSON_HEADERS, + body: JSON.stringify(payload), +}); + +const normalizeText = (value, maxLength) => + typeof value === 'string' ? value.trim().slice(0, maxLength) : ''; + +const escapeMentions = value => value.replace(/@/g, '@\u200b'); + +const truncateText = (value, maxLength) => + value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; + +const getClientIp = event => { + const headers = event.headers || {}; + const header = + headers['x-forwarded-for'] || + headers['X-Forwarded-For'] || + headers['x-nf-client-connection-ip'] || + ''; + + return header.split(',')[0].trim(); +}; + +const verifyTurnstile = async ({ token, secret, clientIp }) => { + const payload = new URLSearchParams(); + payload.append('secret', secret); + payload.append('response', token); + if (clientIp) { + payload.append('remoteip', clientIp); + } + + const response = await fetch(TURNSTILE_VERIFY_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: payload.toString(), + }); + + if (!response.ok) { + return false; + } + + const result = await response.json().catch(() => null); + return !!result?.success; +}; + +const sendToDiscord = async ({ webhookUrl, name, email, message }) => { + const payload = { + allowed_mentions: { + parse: [], + }, + embeds: [ + { + title: 'New Sovryn Support Request', + color: 0xf7931a, + fields: [ + { + name: 'Name', + value: escapeMentions(name), + inline: true, + }, + { + name: 'Email', + value: escapeMentions(email), + inline: true, + }, + ], + description: escapeMentions( + truncateText(message, MAX_DISCORD_DESCRIPTION_LENGTH), + ), + timestamp: new Date().toISOString(), + }, + ], + }; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + return response.ok; +}; + +exports.handler = async event => { + if (event.httpMethod === 'OPTIONS') { + return { + statusCode: 204, + headers: CORS_HEADERS, + body: '', + }; + } + + if (event.httpMethod !== 'POST') { + return jsonResponse(405, { + error: 'Method not allowed.', + code: 'method_not_allowed', + }); + } + + const webhookUrl = process.env.DISCORD_SUPPORT_WEBHOOK_URL; + const turnstileSecret = process.env.TURNSTILE_SECRET_KEY; + + if (!webhookUrl || !turnstileSecret) { + return jsonResponse(500, { + error: 'Support service is not configured.', + code: 'not_configured', + }); + } + + let parsedBody = {}; + + try { + parsedBody = JSON.parse(event.body || '{}'); + } catch { + return jsonResponse(400, { + error: 'Invalid request payload.', + code: 'invalid_payload', + }); + } + + const name = normalizeText(parsedBody.name, MAX_NAME_LENGTH); + const email = normalizeText(parsedBody.email, MAX_EMAIL_LENGTH); + const message = normalizeText(parsedBody.message, MAX_MESSAGE_LENGTH); + const turnstileToken = normalizeText(parsedBody.turnstileToken, 4096); + + if (!name || !email || !message || !turnstileToken) { + return jsonResponse(400, { + error: 'Missing required fields.', + code: 'missing_fields', + }); + } + + if (!EMAIL_REGEX.test(email)) { + return jsonResponse(400, { + error: 'Invalid email address.', + code: 'invalid_email', + }); + } + + const captchaIsValid = await verifyTurnstile({ + token: turnstileToken, + secret: turnstileSecret, + clientIp: getClientIp(event), + }).catch(() => false); + + if (!captchaIsValid) { + return jsonResponse(403, { + error: 'Captcha verification failed. Please try again.', + code: 'captcha_failed', + }); + } + + const delivered = await sendToDiscord({ + webhookUrl, + name, + email, + message, + }).catch(() => false); + + if (!delivered) { + return jsonResponse(502, { + error: 'Unable to deliver your feedback right now. Please try again.', + code: 'discord_failed', + }); + } + + return jsonResponse(200, { ok: true }); +}; diff --git a/apps/frontend/public/index.html b/apps/frontend/public/index.html index fe74d1284..89854ed24 100644 --- a/apps/frontend/public/index.html +++ b/apps/frontend/public/index.html @@ -65,18 +65,9 @@ width: 100%; height: 100%; } - @media (max-width: 640px) { - #tiledesk-container.closed #tiledeskdiv { - bottom: -15px !important; - right: -15px !important; - } - } - + Sovryn - DeFi for bitcoin - <% if (process.env.REACT_APP_TILEDESK_ID) { %> - - - - <% } %> diff --git a/apps/frontend/src/app/3_organisms/Footer/Footer.tsx b/apps/frontend/src/app/3_organisms/Footer/Footer.tsx index 28aaa898c..e67d3d40b 100644 --- a/apps/frontend/src/app/3_organisms/Footer/Footer.tsx +++ b/apps/frontend/src/app/3_organisms/Footer/Footer.tsx @@ -16,6 +16,7 @@ import { import { translations } from '../../../locales/i18n'; import { isStaging } from '../../../utils/helpers'; import { getChangelogUrl } from '../../../utils/helpers'; +import { SupportFeedbackBadge } from '../SupportFeedbackBadge/SupportFeedbackBadge'; type FooterProps = { showDashboardLink?: boolean; @@ -76,42 +77,45 @@ export const Footer: FC = ({ showDashboardLink }) => { ); return ( - - } - links={ -
- {footerLinks.map(link => ( - - ))} -
- } - rightContent={ -
-
- + <> + + } + links={ +
+ {footerLinks.map(link => ( + + ))}
- -
- } - /> + } + rightContent={ +
+
+ +
+ +
+ } + /> + + ); }; diff --git a/apps/frontend/src/app/3_organisms/SupportFeedbackBadge/SupportFeedbackBadge.tsx b/apps/frontend/src/app/3_organisms/SupportFeedbackBadge/SupportFeedbackBadge.tsx new file mode 100644 index 000000000..4443609d4 --- /dev/null +++ b/apps/frontend/src/app/3_organisms/SupportFeedbackBadge/SupportFeedbackBadge.tsx @@ -0,0 +1,436 @@ +import React, { + FC, + FormEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import { nanoid } from 'nanoid'; + +import { + Button, + ButtonStyle, + ButtonType, + Dialog, + DialogBody, + DialogHeader, + DialogSize, + Icon, + IconNames, + Input, + NotificationType, +} from '@sovryn/ui'; + +import { useNotificationContext } from '../../../contexts/NotificationContext'; +import { validateEmail } from '../../../utils/helpers'; + +const SUPPORT_FEEDBACK_ENDPOINT = '/.netlify/functions/support-feedback'; +const TURNSTILE_SCRIPT_ID = 'sovryn-turnstile-script'; +const TURNSTILE_SCRIPT_URL = + 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; +const TURNSTILE_SITE_KEY = process.env.REACT_APP_TURNSTILE_SITE_KEY; + +const MAX_NAME_LENGTH = 80; +const MAX_EMAIL_LENGTH = 254; +const MAX_FEEDBACK_LENGTH = 2000; + +type TurnstileWidgetId = string | number; + +type TurnstileApi = { + render: ( + container: HTMLElement, + options: Record, + ) => TurnstileWidgetId; + remove: (widgetId?: TurnstileWidgetId) => void; + reset: (widgetId?: TurnstileWidgetId) => void; +}; + +type FormErrors = { + name?: string; + email?: string; + feedback?: string; + captcha?: string; +}; + +const getTurnstileApi = () => + (window as Window & { turnstile?: TurnstileApi }).turnstile; + +const loadTurnstileScript = async (): Promise => + new Promise((resolve, reject) => { + if (getTurnstileApi()) { + resolve(); + return; + } + + const existingScript = document.getElementById( + TURNSTILE_SCRIPT_ID, + ) as HTMLScriptElement | null; + + if (existingScript) { + existingScript.addEventListener('load', () => resolve(), { once: true }); + existingScript.addEventListener( + 'error', + () => reject(new Error('Failed to load captcha script')), + { once: true }, + ); + return; + } + + const script = document.createElement('script'); + script.id = TURNSTILE_SCRIPT_ID; + script.src = TURNSTILE_SCRIPT_URL; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load captcha script')); + document.head.appendChild(script); + }); + +export const SupportFeedbackBadge: FC = () => { + const { addNotification } = useNotificationContext(); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [feedback, setFeedback] = useState(''); + const [showCaptcha, setShowCaptcha] = useState(false); + const [turnstileToken, setTurnstileToken] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState({}); + + const captchaContainerRef = useRef(null); + const turnstileWidgetIdRef = useRef(null); + + const removeCaptchaWidget = useCallback(() => { + const turnstile = getTurnstileApi(); + + if (turnstile && turnstileWidgetIdRef.current !== null) { + turnstile.remove(turnstileWidgetIdRef.current); + } + + turnstileWidgetIdRef.current = null; + }, []); + + const resetCaptchaChallenge = useCallback(() => { + setTurnstileToken(''); + + const turnstile = getTurnstileApi(); + if (turnstile && turnstileWidgetIdRef.current !== null) { + turnstile.reset(turnstileWidgetIdRef.current); + } + }, []); + + const resetForm = useCallback(() => { + setName(''); + setEmail(''); + setFeedback(''); + setShowCaptcha(false); + setTurnstileToken(''); + setErrors({}); + setIsSubmitting(false); + removeCaptchaWidget(); + }, [removeCaptchaWidget]); + + const closeDialog = useCallback(() => { + setIsDialogOpen(false); + resetForm(); + }, [resetForm]); + + const validateFields = useCallback(() => { + const nextErrors: FormErrors = {}; + + const normalizedName = name.trim(); + const normalizedEmail = email.trim(); + const normalizedFeedback = feedback.trim(); + + if (!normalizedName) { + nextErrors.name = 'Name is required.'; + } + + if (!normalizedEmail) { + nextErrors.email = 'Email is required.'; + } else if (!validateEmail(normalizedEmail)) { + nextErrors.email = 'Enter a valid email address.'; + } + + if (!normalizedFeedback) { + nextErrors.feedback = 'Feedback is required.'; + } + + return { + nextErrors, + normalizedName, + normalizedEmail, + normalizedFeedback, + }; + }, [email, feedback, name]); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + if (isSubmitting) { + return; + } + + const { + nextErrors, + normalizedName, + normalizedEmail, + normalizedFeedback, + } = validateFields(); + + if (!TURNSTILE_SITE_KEY) { + nextErrors.captcha = + 'Support form is unavailable. Captcha is not configured.'; + } + + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + return; + } + + if (!turnstileToken) { + setShowCaptcha(true); + setErrors({ + captcha: 'Complete the captcha challenge and submit again.', + }); + return; + } + + setIsSubmitting(true); + setErrors({}); + + try { + const response = await fetch(SUPPORT_FEEDBACK_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: normalizedName, + email: normalizedEmail, + message: normalizedFeedback, + turnstileToken, + }), + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + resetCaptchaChallenge(); + + if (payload?.code === 'captcha_failed') { + setErrors({ + captcha: + 'Captcha verification failed. Complete the challenge again.', + }); + } + + addNotification({ + type: NotificationType.error, + title: + payload?.error || + 'Unable to send your message right now. Please try again.', + dismissible: true, + id: nanoid(), + }); + return; + } + + addNotification({ + type: NotificationType.success, + title: 'Message sent. Our support team will reach out soon.', + dismissible: true, + id: nanoid(), + }); + + closeDialog(); + } catch { + resetCaptchaChallenge(); + addNotification({ + type: NotificationType.error, + title: 'Unable to send your message right now. Please try again.', + dismissible: true, + id: nanoid(), + }); + } finally { + setIsSubmitting(false); + } + }, + [ + addNotification, + closeDialog, + isSubmitting, + resetCaptchaChallenge, + turnstileToken, + validateFields, + ], + ); + + useEffect(() => { + if (!isDialogOpen || !showCaptcha || !TURNSTILE_SITE_KEY) { + return; + } + + if (!captchaContainerRef.current || turnstileWidgetIdRef.current !== null) { + return; + } + + let cancelled = false; + + loadTurnstileScript() + .then(() => { + if ( + cancelled || + !captchaContainerRef.current || + turnstileWidgetIdRef.current !== null + ) { + return; + } + + const turnstile = getTurnstileApi(); + if (!turnstile) { + setErrors({ + captcha: 'Captcha failed to load. Please refresh and try again.', + }); + return; + } + + turnstileWidgetIdRef.current = turnstile.render( + captchaContainerRef.current, + { + sitekey: TURNSTILE_SITE_KEY, + theme: 'dark', + callback: (token: string) => { + setTurnstileToken(token); + setErrors(prev => ({ ...prev, captcha: undefined })); + }, + 'expired-callback': () => { + setTurnstileToken(''); + }, + 'error-callback': () => { + setTurnstileToken(''); + setErrors({ + captcha: 'Captcha verification failed. Please try again.', + }); + }, + }, + ); + }) + .catch(() => { + if (!cancelled) { + setErrors({ + captcha: 'Captcha failed to load. Please refresh and try again.', + }); + } + }); + + return () => { + cancelled = true; + }; + }, [isDialogOpen, showCaptcha]); + + useEffect( + () => () => { + removeCaptchaWidget(); + }, + [removeCaptchaWidget], + ); + + return ( + <> + + + + + +
+
+ + + {errors.name && ( +

{errors.name}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ +