mirror of
https://github.com/FoggedLens/deflock.git
synced 2026-04-28 22:36:15 +02:00
Merge branch 'master' into new-maps
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
ZAMMAD_URL=https://pigeon.deflock.org
|
||||
GITHUB_TOKEN=
|
||||
ZAMMAD_TOKEN=
|
||||
TURNSTILE_SECRET_KEY=
|
||||
+31
-1
@@ -3,6 +3,8 @@ import Fastify, { FastifyInstance, FastifyError } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { NominatimClient, NominatimResultSchema } from './services/NominatimClient';
|
||||
import { GithubClient, SponsorsResponseSchema } from './services/GithubClient';
|
||||
import { TurnstileClient } from './services/TurnstileClient';
|
||||
import { ZammadClient, ContactMessageBodySchema, ContactMessageBody } from './services/ZammadClient';
|
||||
|
||||
const start = async () => {
|
||||
const server: FastifyInstance = Fastify({
|
||||
@@ -51,11 +53,13 @@ const start = async () => {
|
||||
cb(null, false);
|
||||
}
|
||||
},
|
||||
methods: ['GET', 'HEAD'],
|
||||
methods: ['GET', 'HEAD', 'POST'],
|
||||
});
|
||||
|
||||
const nominatim = new NominatimClient();
|
||||
const githubClient = new GithubClient();
|
||||
const turnstileClient = new TurnstileClient();
|
||||
const zammadClient = new ZammadClient();
|
||||
|
||||
const shutdown = async () => {
|
||||
server.log.info("Shutting down");
|
||||
@@ -77,6 +81,7 @@ const start = async () => {
|
||||
},
|
||||
response: {
|
||||
200: NominatimResultSchema,
|
||||
404: { type: 'object', properties: { error: { type: 'string' } } },
|
||||
500: { type: 'object', properties: { error: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
@@ -84,6 +89,9 @@ const start = async () => {
|
||||
const { query } = request.query as { query: string };
|
||||
reply.header('Cache-Control', 'public, max-age=86400, s-maxage=86400');
|
||||
const result = await nominatim.geocodeSingleResult(query);
|
||||
if (!result) {
|
||||
return reply.status(404).send({ error: 'No results found' });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -131,6 +139,28 @@ const start = async () => {
|
||||
return result;
|
||||
});
|
||||
|
||||
server.post('/contact/message', {
|
||||
schema: {
|
||||
body: ContactMessageBodySchema,
|
||||
response: {
|
||||
201: { type: 'object', properties: {} },
|
||||
400: { type: 'object', properties: { error: { type: 'string' } } },
|
||||
500: { type: 'object', properties: { error: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { name, email, topic, subject, message, turnstileToken } = request.body as ContactMessageBody;
|
||||
|
||||
const remoteIp = request.ip;
|
||||
const valid = await turnstileClient.verify(turnstileToken, remoteIp);
|
||||
if (!valid) {
|
||||
return reply.status(400).send({ error: 'Invalid captcha' });
|
||||
}
|
||||
|
||||
await zammadClient.createTicket({ name, email, topic, subject, message });
|
||||
return reply.status(201).send({});
|
||||
});
|
||||
|
||||
server.head('/healthcheck', async (request, reply) => {
|
||||
reply.status(200).send();
|
||||
});
|
||||
|
||||
@@ -17,9 +17,9 @@ export const NominatimResultSchema = Type.Object({
|
||||
licence: Type.String(),
|
||||
lon: Type.String(),
|
||||
name: Type.String(),
|
||||
osm_id: Type.Number(),
|
||||
osm_type: Type.String(),
|
||||
place_id: Type.Number(),
|
||||
osm_id: Type.Optional(Type.Number()),
|
||||
osm_type: Type.Optional(Type.String()),
|
||||
place_id: Type.Optional(Type.Number()),
|
||||
place_rank: Type.Number(),
|
||||
type: Type.String(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const TURNSTILE_SECRET_KEY = process.env.TURNSTILE_SECRET_KEY || '';
|
||||
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
|
||||
export class TurnstileClient {
|
||||
async verify(token: string, remoteIp?: string): Promise<boolean> {
|
||||
const body = new URLSearchParams({
|
||||
secret: TURNSTILE_SECRET_KEY,
|
||||
response: token,
|
||||
...(remoteIp ? { remoteip: remoteIp } : {}),
|
||||
});
|
||||
|
||||
const response = await fetch(SITEVERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Turnstile siteverify request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json() as { success: boolean };
|
||||
return json.success === true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Type, Static } from '@sinclair/typebox';
|
||||
|
||||
const ZAMMAD_URL = process.env.ZAMMAD_URL || '';
|
||||
const ZAMMAD_TOKEN = process.env.ZAMMAD_TOKEN || '';
|
||||
|
||||
export type ContactTopic =
|
||||
| 'website-support'
|
||||
| 'app-support'
|
||||
| 'local-groups'
|
||||
| 'media'
|
||||
| 'questions-comments';
|
||||
|
||||
export const ContactMessageBodySchema = Type.Object({
|
||||
name: Type.String({ minLength: 1 }),
|
||||
email: Type.String({ format: 'email' }),
|
||||
topic: Type.Union([
|
||||
Type.Literal('website-support'),
|
||||
Type.Literal('app-support'),
|
||||
Type.Literal('local-groups'),
|
||||
Type.Literal('media'),
|
||||
Type.Literal('questions-comments'),
|
||||
]),
|
||||
subject: Type.String({ minLength: 1 }),
|
||||
message: Type.String({ minLength: 1 }),
|
||||
turnstileToken: Type.String({ minLength: 1 }),
|
||||
});
|
||||
|
||||
export type ContactMessageBody = Static<typeof ContactMessageBodySchema>;
|
||||
|
||||
const TOPIC_GROUP_MAP: Record<ContactTopic, string> = {
|
||||
'website-support': 'Website Support',
|
||||
'app-support': 'App Support',
|
||||
'local-groups': 'Local Groups',
|
||||
'media': 'Media',
|
||||
'questions-comments': 'General Support',
|
||||
};
|
||||
|
||||
export interface CreateTicketPayload {
|
||||
name: string;
|
||||
email: string;
|
||||
topic: ContactTopic;
|
||||
subject: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ZammadClient {
|
||||
async createTicket(payload: CreateTicketPayload): Promise<void> {
|
||||
const { name, email, topic, subject, message } = payload;
|
||||
const group = TOPIC_GROUP_MAP[topic];
|
||||
|
||||
const body = JSON.stringify({
|
||||
title: subject,
|
||||
group,
|
||||
priority: topic === 'media' ? '3 high' : '2 normal',
|
||||
customer: email,
|
||||
article: {
|
||||
subject,
|
||||
body: message,
|
||||
type: 'email',
|
||||
sender: 'Customer',
|
||||
from: `${name} <${email}>`,
|
||||
to: 'contact@deflock.org',
|
||||
internal: false,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${ZAMMAD_URL}/api/v1/tickets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Token token=${ZAMMAD_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Zammad ticket creation failed: ${response.status} ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<v-alert>
|
||||
<p class="text-h5 my-0 pt-0 font-weight-bold">Don't see a group near you?</p>
|
||||
<p>
|
||||
Write to us at <a href="mailto:groups@deflock.org">groups@deflock.org</a> for info on starting your own chapter.
|
||||
<router-link to="/contact?topic=local-groups">Send us a message</router-link> for info on starting your own chapter.
|
||||
</p>
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,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> or directly to <a href="mailto:media@deflock.org">media@deflock.org</a>.
|
||||
</p>
|
||||
|
||||
</v-container>
|
||||
|
||||
Reference in New Issue
Block a user