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 (
+ <>
+
+
+
+ >
+ );
+};
diff --git a/apps/frontend/src/react-app-env.d.ts b/apps/frontend/src/react-app-env.d.ts
index 1bb79ed05..0e87bc45b 100644
--- a/apps/frontend/src/react-app-env.d.ts
+++ b/apps/frontend/src/react-app-env.d.ts
@@ -6,5 +6,6 @@ declare namespace NodeJS {
readonly REACT_APP_CHAIN_ID: string;
readonly REACT_APP_ENABLE_SERVICE_WORKER: string;
readonly REACT_APP_RELEASE_DATA: string;
+ readonly REACT_APP_TURNSTILE_SITE_KEY: string;
}
}