mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-04-30 15:17:51 +02:00
Contact Form (#113)
* contact form on frontend * note about reporting cameras via email * cleanup
This commit is contained in:
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user