Contact Form (#113)

* contact form on frontend

* note about reporting cameras via email

* cleanup
This commit is contained in:
Will Freeman
2026-04-21 12:16:18 -06:00
committed by GitHub
parent 7c27235400
commit 5316246c0e
5 changed files with 265 additions and 145 deletions
+10
View File
@@ -17,6 +17,7 @@
"pinia": "^2.3.0",
"vue": "^3.4.29",
"vue-router": "^4.3.3",
"vue-turnstile": "^1.0.11",
"vuetify": "^3.7.2"
},
"devDependencies": {
@@ -2181,6 +2182,15 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue-turnstile": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/vue-turnstile/-/vue-turnstile-1.0.11.tgz",
"integrity": "sha512-iaTBoZ5oUqtNRto6bmbn6FQvW0h/sK7mPUJc1Qn4em+cELXN59U2FQTcpWfKssV3OY6lEZzmCpcn/zrb7htK3A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.45"
}
},
"node_modules/vuetify": {
"version": "3.11.2",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.2.tgz",
+1
View File
@@ -21,6 +21,7 @@
"pinia": "^2.3.0",
"vue": "^3.4.29",
"vue-router": "^4.3.3",
"vue-turnstile": "^1.0.11",
"vuetify": "^3.7.2"
},
"devDependencies": {
+14
View File
@@ -87,3 +87,17 @@ export const geocodeQuery = async (query: string) => {
const result = (await apiService.get(`/geocode?query=${encodedQuery}`)).data;
return result;
}
export interface ContactMessagePayload {
name: string;
email: string;
topic: string;
subject: string;
message: string;
turnstileToken: string;
}
export const postContactMessage = async (payload: ContactMessagePayload) => {
const response = await apiService.post("/contact/message", payload);
return response.data;
}
+239 -144
View File
@@ -1,161 +1,256 @@
<template>
<DefaultLayout>
<v-container>
<v-row justify="center" class="mb-8">
<v-col cols="12" md="8" class="text-center">
<h1 class="text-h3 font-weight-bold mb-4">Contact Us</h1>
<p class="text-h6 text-medium-emphasis">
How can we help you today?
</p>
</v-col>
</v-row>
<DefaultLayout>
<v-container>
<v-row justify="center" class="mb-6">
<v-col cols="12" md="8" class="text-center">
<h1 class="text-h3 font-weight-bold mb-4">Contact Us</h1>
<p class="text-h6 text-medium-emphasis">
We'd love to hear from you.
</p>
</v-col>
</v-row>
<v-row justify="center">
<v-col cols="12" md="10" lg="8">
<v-row>
<!-- Report Camera Option -->
<v-col cols="12" md="6">
<v-card
variant="outlined"
class="mx-auto h-100 d-flex flex-column"
max-width="400"
hover
@click="$router.push('/report')"
style="cursor: pointer;"
>
<v-card-item class="text-center pa-6">
<v-icon
icon="mdi-camera"
size="48"
color="primary"
class="mb-4"
></v-icon>
<v-card-title class="text-h5 font-weight-bold card-title-wrap">
Report a Camera
</v-card-title>
<v-card-subtitle class="text-body-1 mt-2 card-subtitle-wrap">
Found an ALPR camera? Help us map it and protect your community's privacy.
</v-card-subtitle>
</v-card-item>
<v-card-actions class="justify-center pb-6">
<v-row justify="center">
<v-col cols="12" md="8" lg="6">
<!-- Success state -->
<v-alert
v-if="isSuccess"
type="success"
variant="tonal"
class="mb-6"
title="Message sent!"
>
Thanks for reaching out. We'll get back to you as soon as we can.
</v-alert>
<!-- Error state -->
<v-alert
v-if="isError"
type="error"
variant="tonal"
class="mb-6"
title="Something went wrong"
>
We couldn't send your message. Please try again, or email us directly at
<a href="mailto:contact@deflock.org" class="text-decoration-none font-weight-bold">contact@deflock.org</a>.
</v-alert>
<v-form ref="form" @submit.prevent="handleSubmit">
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="fields.name"
label="Name or alias"
:rules="[rules.required]"
variant="outlined"
density="comfortable"
autocomplete="name"
></v-text-field>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="fields.email"
label="Email"
type="email"
:rules="[rules.required, rules.email]"
variant="outlined"
density="comfortable"
autocomplete="email"
></v-text-field>
</v-col>
<v-col cols="12">
<v-select
v-model="fields.topic"
label="What can we help with?"
:items="topicItems"
item-title="label"
item-value="value"
:rules="[rules.required]"
variant="outlined"
density="comfortable"
></v-select>
</v-col>
<v-col v-if="fields.topic === REPORT_CAMERA_TOPIC" cols="12">
<v-alert type="info" variant="tonal" icon="mdi-camera">
We don't accept camera submissions by email but you can
<router-link :to="{ name: 'report' }">report a camera yourself</router-link>
directly on the site. If you're having trouble with the reporting tool, feel free to send us a message below.
</v-alert>
</v-col>
<v-col cols="12">
<v-text-field
v-model="fields.subject"
label="Subject"
:rules="[rules.required]"
variant="outlined"
density="comfortable"
></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea
v-model="fields.message"
label="Message"
:rules="[rules.required]"
variant="outlined"
rows="5"
auto-grow
></v-textarea>
</v-col>
<v-col cols="12">
<VueTurnstile
ref="turnstileRef"
site-key="0x4AAAAAADApg9O-hmUZCotn"
v-model="turnstileToken"
theme="auto"
@error="onTurnstileError"
@expired="onTurnstileExpired"
/>
<p v-if="turnstileError" class="text-caption text-error mt-1">
Please complete the security check.
</p>
</v-col>
<v-col cols="12">
<v-btn
type="submit"
color="primary"
size="large"
append-icon="mdi-arrow-right"
:loading="isSubmitting"
:disabled="isSubmitting"
block
>
Start Report
Send Message
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-col>
</v-row>
</v-form>
<!-- General Inquiry Option -->
<v-col cols="12" md="6">
<v-card
variant="outlined"
class="mx-auto h-100 d-flex flex-column"
max-width="400"
:hover="!showContactOptions"
@click="!showContactOptions ? showContactOptions = true : null"
:style="showContactOptions ? 'cursor: default;' : 'cursor: pointer;'"
>
<v-card-item class="text-center pa-6">
<v-icon
:icon="showContactOptions ? 'mdi-message-text' : 'mdi-message-text'"
size="48"
color="secondary"
class="mb-4"
></v-icon>
<v-card-title class="text-h5 font-weight-bold card-title-wrap">
{{ showContactOptions ? 'Get in Touch' : 'General Inquiry' }}
</v-card-title>
<v-card-subtitle class="text-body-1 mt-2 card-subtitle-wrap">
{{ showContactOptions ? 'Send us an email with your questions' : 'Have questions about DeFlock, need help, or want to get involved?' }}
</v-card-subtitle>
</v-card-item>
<v-card-actions class="justify-center pb-6">
<div v-if="!showContactOptions">
<v-btn
color="secondary"
size="large"
append-icon="mdi-arrow-right"
>
Contact Options
</v-btn>
</div>
<div v-else class="d-flex flex-column gap-3 w-100">
<div class="text-center">
<p class="text-body-1 mb-2">Send us an email at:</p>
<a
href="mailto:contact@deflock.org"
class="text-h6 font-weight-bold text-decoration-none"
style="color: rgb(var(--v-theme-secondary));"
>
contact@deflock.org
</a>
</div>
</div>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-col>
</v-row>
<p class="text-center text-body-2 text-medium-emphasis mt-8">
<b>Media inquiries?</b> Visit our
<router-link to="/press" class="text-decoration-none">
press page
</router-link>.
</p>
</v-col>
</v-row>
<!-- Additional Help Section -->
<v-row justify="center">
<v-col cols="12" md="8" class="text-center">
<v-divider class="mb-6"></v-divider>
<p class="text-body-2 text-medium-emphasis">
Need to identify if something is an ALPR camera?<br>
<router-link to="/identify" class="text-decoration-none">
Check our camera gallery
</router-link>
or learn more about
<router-link to="/what-is-an-alpr" class="text-decoration-none">
what ALPRs are
</router-link>.
</p>
</v-col>
</v-row>
</v-container>
</DefaultLayout>
<v-divider class="mt-12" />
<v-row>
<v-col cols="12" md="8" class="mx-auto text-center">
<h2 class="text-h5 font-weight-bold mb-3">Other Ways to Reach Us</h2>
<p class="text-body-1 mb-4">
If you prefer, you can also email us directly at
<a href="mailto:contact@deflock.org" class="text-decoration-none font-weight-bold">contact@deflock.org</a>.
</p>
</v-col>
</v-row>
</v-container>
</DefaultLayout>
</template>
<script setup lang="ts">
import DefaultLayout from '@/layouts/DefaultLayout.vue';
import { useTheme } from 'vuetify';
import { computed, ref } from 'vue';
import { postContactMessage } from '@/services/apiService';
import VueTurnstile from 'vue-turnstile';
import { nextTick, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
const theme = useTheme();
const isDark = computed(() => theme.name.value === 'dark');
const showContactOptions = ref(false);
const route = useRoute();
const topicItems = [
{ label: 'Questions & Comments', value: 'questions-comments' },
{ label: 'Technical Support - Website/Map', value: 'website-support' },
{ label: 'Technical Support - DeFlock App', value: 'app-support' },
{ label: 'Reporting a Camera', value: 'report-camera' },
{ label: 'Local Groups', value: 'local-groups' },
{ label: 'Media/Press', value: 'media' },
] as const;
type TopicValue = typeof topicItems[number]['value'];
const REPORT_CAMERA_TOPIC = 'report-camera' as const;
const validTopics = topicItems.map((t) => t.value) as string[];
const initialTopic = (): TopicValue | null => {
const q = route.query.topic as string | undefined;
return q && validTopics.includes(q) ? (q as TopicValue) : null;
};
const fields = reactive({
name: '',
email: '',
topic: initialTopic(),
subject: '',
message: '',
});
const rules = {
required: (v: unknown) => (v !== null && v !== undefined && String(v).trim() !== '') || 'This field is required.',
email: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Please enter a valid email address.',
};
const form = ref<{ validate: () => Promise<{ valid: boolean }>; resetValidation: () => void } | null>(null);
const turnstileRef = ref<InstanceType<typeof VueTurnstile> | null>(null);
const turnstileToken = ref('');
const turnstileError = ref(false);
const isSubmitting = ref(false);
const isSuccess = ref(false);
const isError = ref(false);
const onTurnstileError = () => {
turnstileToken.value = '';
};
const onTurnstileExpired = () => {
turnstileToken.value = '';
};
const resetTurnstile = () => {
turnstileRef.value?.reset();
turnstileToken.value = '';
};
const handleSubmit = async () => {
isError.value = false;
isSuccess.value = false;
const { valid } = await form.value!.validate();
if (!valid) return;
if (!turnstileToken.value) {
turnstileError.value = true;
return;
}
turnstileError.value = false;
isSubmitting.value = true;
try {
await postContactMessage({
name: fields.name,
email: fields.email,
topic: fields.topic === REPORT_CAMERA_TOPIC ? 'app-support' : fields.topic!,
subject: fields.subject,
message: fields.message,
turnstileToken: turnstileToken.value,
});
isSuccess.value = true;
// Reset form
fields.name = '';
fields.email = '';
fields.topic = null;
fields.subject = '';
fields.message = '';
await nextTick();
form.value!.resetValidation();
resetTurnstile();
} catch {
isError.value = true;
resetTurnstile();
} finally {
isSubmitting.value = false;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
</script>
<style lang="css" scoped>
.card-title-wrap {
white-space: normal !important;
overflow: visible !important;
text-overflow: unset !important;
}
.card-subtitle-wrap {
white-space: normal !important;
overflow: visible !important;
text-overflow: unset !important;
line-height: 1.4 !important;
}
.gap-3 > * + * {
margin-top: 12px;
}
</style>
+1 -1
View File
@@ -43,7 +43,7 @@
<h2>Contact Us</h2>
<p>
For media inquiries and interview requests, send us an email at <a href="mailto:media@deflock.org">media@deflock.org</a>.
For media inquiries and interview requests, send us an email <router-link to="/contact?topic=media">using this form</router-link>.
</p>
</v-container>