Skip to content

Commit 2ef17d4

Browse files
committed
feat: implement contact form.
1 parent 9f7cf8a commit 2ef17d4

7 files changed

Lines changed: 167 additions & 7 deletions

File tree

server-side/contact.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { AutoRouter, cors, error } from "npm:itty-router";
2+
3+
const { preflight, corsify } = cors();
4+
const router = AutoRouter({
5+
before: [preflight],
6+
finally: [corsify],
7+
});
8+
9+
const contactWebhook = Deno.env.get("CONTACT_WEBHOOK");
10+
if (!contactWebhook) throw "Missing CONTACT_WEBHOOK env var";
11+
const turnstileSecret = Deno.env.get("TURNSTILE_SECRET");
12+
if (!turnstileSecret) throw "Missing TURNSTILE_SECRET env var";
13+
14+
router.post("/contact", async ({ formData }) => {
15+
const data = await formData();
16+
const cloudflareToken = data.get("cf-turnstile-response");
17+
if (!cloudflareToken) return error(400, "Cloudflare captcha token missing.");
18+
const name = data.get("name");
19+
const email = data.get("email");
20+
if (!email) return error(400, "Missing email field");
21+
const message = data.get("message");
22+
if (!message) return error(400, "Missing message field");
23+
24+
const turnstileData = new FormData();
25+
turnstileData.append("secret", turnstileSecret);
26+
turnstileData.append("response", cloudflareToken);
27+
// turnstileData.append("remoteip", ip);
28+
29+
const url = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
30+
const result = await fetch(url, {
31+
body: turnstileData,
32+
method: "POST",
33+
});
34+
if (!result.ok) {
35+
console.error("Error validating turnstile challenge:", await result.text());
36+
return error(400, "Invalid turnstile challenge response");
37+
}
38+
39+
const response = await fetch(contactWebhook, {
40+
method: "POST",
41+
headers: {
42+
"Content-Type": "application/json",
43+
},
44+
body: JSON.stringify({
45+
text: `New message from contact form:`,
46+
attachments: [
47+
{
48+
title: `${name}${name && " "}<${email}>`,
49+
text: message,
50+
},
51+
],
52+
}),
53+
});
54+
if (response.ok) {
55+
return new Response(undefined, { status: 200 });
56+
} else {
57+
return new Response(undefined, {
58+
status: 500,
59+
statusText: "Failed to send message to contact webhook.",
60+
});
61+
}
62+
});
63+
64+
Deno.serve(router.fetch);

server-side/deno.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"nodeModulesDir": "auto"
3+
}

server-side/deno.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/ui/Form.astro

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22
import type { Form as Props } from '~/types';
33
import Button from '~/components/ui/Button.astro';
44
5+
const isProd = import.meta.env.PROD;
6+
const siteKey = isProd ? '0x4AAAAAAByo9pKriiVamG2A' : '3x00000000000000000000FF';
7+
58
const { inputs, textarea, disclaimer, button = 'Contact us', description = '' } = Astro.props;
69
---
710

811
<form>
912
{
1013
inputs &&
1114
inputs.map(
12-
({ type = 'text', name, label = '', autocomplete = 'on', placeholder = '' }) =>
15+
({ type = 'text', name, label = '', autocomplete = 'on', placeholder = '', required = false }) =>
1316
name && (
1417
<div class="mb-6">
1518
{label && (
@@ -23,6 +26,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
2326
id={name}
2427
autocomplete={autocomplete}
2528
placeholder={placeholder}
29+
required={required}
2630
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
2731
/>
2832
</div>
@@ -42,6 +46,7 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
4246
rows={textarea.rows ? textarea.rows : 4}
4347
placeholder={textarea.placeholder}
4448
class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900"
49+
required={textarea.required || false}
4550
/>
4651
</div>
4752
)
@@ -69,7 +74,10 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
6974

7075
{
7176
button && (
72-
<div class="mt-10 grid">
77+
<div class="grid">
78+
<div class="checkbox my-3 mx-auto">
79+
<div class="cf-turnstile" data-sitekey={siteKey} data-theme="dark" />
80+
</div>
7381
<Button variant="primary" type="submit">
7482
{button}
7583
</Button>
@@ -85,3 +93,54 @@ const { inputs, textarea, disclaimer, button = 'Contact us', description = '' }
8593
)
8694
}
8795
</form>
96+
97+
<script define:vars={{ siteKey }} is:inline>
98+
window.onloadTurnstileCallback = function () {
99+
turnstile.render('#turnstile-challenge', {
100+
sitekey: siteKey,
101+
callback: function (_) {
102+
document.querySelector('button').disabled = false;
103+
},
104+
});
105+
};
106+
107+
// Add event listener for form submission
108+
document.querySelector('form')?.addEventListener('submit', async (e) => {
109+
e.preventDefault();
110+
const formData = new FormData(e.target);
111+
112+
// Call the contact webhook
113+
try {
114+
const response = await fetch('https://ktech-studio-contact-form.deno.dev/contact', {
115+
method: 'POST',
116+
body: formData,
117+
});
118+
119+
if (!response.ok) {
120+
throw new Error('Network response was not ok');
121+
}
122+
123+
window
124+
.Toastify({
125+
text: 'Message sent successfully',
126+
style: { background: 'green' },
127+
})
128+
.showToast();
129+
130+
// Clear the form fields
131+
document.querySelectorAll('input,textarea').forEach((x) => (x.value = ''));
132+
window.turnstile.reset();
133+
document.querySelector('button').disabled = true;
134+
} catch (error) {
135+
console.error('Error sending message:', error);
136+
window
137+
.Toastify({
138+
text: 'Error sending message',
139+
style: { background: 'red' },
140+
})
141+
.showToast();
142+
}
143+
});
144+
</script>
145+
146+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback" defer></script>

src/layouts/Layout.astro

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const { language, textDirection } = I18N;
3636
<SiteVerification />
3737
<Analytics />
3838

39+
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css" />
40+
3941
<!-- Comment the line below to disable View Transitions -->
4042
<ClientRouter fallback="swap" />
4143
</head>
@@ -44,5 +46,7 @@ const { language, textDirection } = I18N;
4446
<slot />
4547

4648
<BasicScripts />
49+
50+
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
4751
</body>
4852
</html>

src/pages/contact.astro

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,20 @@ const metadata = {
1717
type: 'text',
1818
name: 'name',
1919
label: 'Name',
20-
placeholder: 'In case it helps to know who you are...',
20+
placeholder: 'In case it helps to know who you are...'
2121
},
2222
{
2323
type: 'email',
2424
name: 'email',
2525
label: 'Email',
2626
placeholder: 'If you want a response...',
27+
required: true,
2728
},
2829
]}
2930
textarea={{
3031
label: 'Message',
3132
placeholder: 'If you want to actually be useful... 🤨',
32-
}}
33-
disclaimer={{
34-
label:
35-
'By submitting this contact form, you acknowledge and agree to the collection of your personal information.',
33+
required: true,
3634
}}
3735
description="Thanks for reaching out!"
3836
/>

src/types.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
11
import type { AstroComponentFactory } from 'astro/runtime/server/index.js';
22
import type { HTMLAttributes, ImageMetadata } from 'astro/types';
33

4+
declare global {
5+
interface Window {
6+
/** Incomplete types from: https://github.com/apvarun/toastify-js/tree/master?tab=readme-ov-file#api */
7+
Toastify(options: {
8+
text: string;
9+
className?: string;
10+
style?: {
11+
background?: string;
12+
};
13+
offset?: {
14+
x?: number;
15+
y?: number;
16+
};
17+
gravity?: 'top' | 'bottom';
18+
position?: 'left' | 'right';
19+
}): { showToast(): void };
20+
}
21+
}
22+
423
export interface Post {
524
/** A unique ID number that identifies a post. */
625
id: string;
@@ -169,13 +188,15 @@ export interface Input {
169188
label?: string;
170189
autocomplete?: string;
171190
placeholder?: string;
191+
required?: boolean;
172192
}
173193

174194
export interface Textarea {
175195
label?: string;
176196
name?: string;
177197
placeholder?: string;
178198
rows?: number;
199+
required?: boolean;
179200
}
180201

181202
export interface Disclaimer {

0 commit comments

Comments
 (0)