contacts form

This commit is contained in:
Sergey Yarkov 2025-12-18 11:37:37 +03:00
parent 26e6276b23
commit 838a74c086
15 changed files with 230 additions and 50 deletions

View File

@ -1,2 +1,3 @@
DIRECTUS_URL=
DIRECTUS_URL_GRAPHQL=
DIRECTUS_TOKEN=

View File

@ -12,12 +12,21 @@ export default defineConfig({
site: "https://yarkov.tech",
compressHTML: true,
output: "server",
server: {
port: 9966,
allowedHosts: ["showcase.yarkov.tech"],
},
adapter: node({
mode: "standalone",
}),
env: {
schema: {
DIRECTUS_URL: envField.string({ context: "server", access: "secret", default: "" }),
DIRECTUS_URL_GRAPHQL: envField.string({
context: "server",
access: "secret",
default: "http://directus:8055/graphql",
}),
DIRECTUS_TOKEN: envField.string({ context: "server", access: "secret", default: "" }),
},
},

View File

@ -11,11 +11,12 @@
"license": "MIT",
"type": "module",
"scripts": {
"dev": "astro dev",
"dev": "astro dev --host",
"build": "astro check && astro build",
"start": "node --env-file=.env ./dist/server/entry.mjs",
"preview": "astro preview",
"astro": "astro",
"telemetry-disable": "astro telemetry disable",
"codegen": "graphql-codegen --require dotenv/config --config codegen.ts --watch"
},
"dependencies": {

41
src/actions/index.ts Normal file
View File

@ -0,0 +1,41 @@
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { createAppeal, getLatestAppealsByClientIp } from "../queries";
const CREATE_APPEAL_LIMIT_HRS = 1;
const APPEALS_LAST_HRS_LIMIT = 2;
export const server = {
appeal: defineAction({
accept: "form",
input: z.object({
name: z.string(),
email: z.string().email(),
subject: z.string(),
message: z.string(),
}),
handler: async (data, ctx) => {
const currentDate = new Date();
const appealsLastHrs = (await getLatestAppealsByClientIp(ctx.clientAddress, 5)).filter((a) => {
const hourDiff = (currentDate.getTime() - new Date(a.date_created).getTime()) / (1000 * 60 * 60);
return hourDiff <= CREATE_APPEAL_LIMIT_HRS;
});
if (appealsLastHrs.length >= APPEALS_LAST_HRS_LIMIT) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Appeal create limit exceeded.",
});
}
const appeal = await createAppeal({ ...data, client_ip: ctx.clientAddress });
if (appeal) return true;
throw new ActionError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to create appeal.",
});
},
}),
};

View File

@ -2,17 +2,20 @@
import Button from "@components/Button.astro";
import UIString from "./UIString.astro";
import { useTranslation } from "@i18n/utils";
import { actions } from "astro:actions";
const t = useTranslation(Astro);
const formResult = Astro.getActionResult(actions.appeal);
---
<form name="contact" action="/api/appeal" method="post">
<>
<form name="contact" action={actions.appeal} method="post">
<div class="flex">
<div>
<input name="name" placeholder={t("input.nameField")} type="text" maxlength="20" autocomplete="off" required />
</div>
<div>
<input name="email" placeholder="example@email.com" autocomplete="off" type="email" required />
<input name="email" placeholder="example@email.com" type="email" autocomplete="off" required />
</div>
</div>
<input name="subject" placeholder={t("input.subjectField")} type="text" autocomplete="off" required />
@ -25,11 +28,19 @@ const t = useTranslation(Astro);
rows={10}
required></textarea>
<div class="footer">
{
formResult?.error && (
<span>
<UIString key={formResult.error.status === 429 ? "submitted.limit" : "submitted.error"} />
</span>
)
}
<Button type={"submit"}>
<UIString key={"btn.submit"} />
</Button>
</div>
</form>
</form>
</>
<style lang="scss">
form {
@ -48,7 +59,10 @@ const t = useTranslation(Astro);
border: 1px solid var(--color-error);
}
& .footer {
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
}
@media screen and (max-width: 560px) {

View File

@ -1,12 +1,12 @@
import type { ExecutionResult } from "graphql";
import type { TypedDocumentString } from "./graphql";
import { DIRECTUS_URL, DIRECTUS_TOKEN } from "astro:env/server";
import { DIRECTUS_URL_GRAPHQL, DIRECTUS_TOKEN } from "astro:env/server";
export default async function execute<TResult, TVariables>(
query: TypedDocumentString<TResult, TVariables>,
...[variables]: TVariables extends Record<string, never> ? [] : [TVariables]
) {
const response = await fetch(DIRECTUS_URL + "/graphql", {
const response = await fetch(DIRECTUS_URL_GRAPHQL, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@ -15,6 +15,8 @@ import * as types from './graphql';
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
"\n\t\tquery LatestAppealByClientIpQuery($client_ip: String!, $limit: Int!) {\n\t\t\tappeal(filter: { client_ip: { _eq: $client_ip } }, sort: [\"-date_created\"], limit: $limit) {\n\t\t\t\tclient_ip\n\t\t\t\tdate_created\n\t\t\t}\n\t\t}\n\t": typeof types.LatestAppealByClientIpQueryDocument,
"\n\t\tmutation CreateAppealQuery(\n\t\t\t$name: String!\n\t\t\t$email: String!\n\t\t\t$subject: String!\n\t\t\t$message: String!\n\t\t\t$client_ip: String!\n\t\t) {\n\t\t\tcreate_appeal_item(\n\t\t\t\tdata: { name: $name, email: $email, subject: $subject, message: $message, client_ip: $client_ip }\n\t\t\t) {\n\t\t\t\tid\n\t\t\t}\n\t\t}\n\t": typeof types.CreateAppealQueryDocument,
"\n\t\tquery Article {\n\t\t\tarticle {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\tid\n\t\t\t\t\ttitle\n\t\t\t\t\ttags {\n\t\t\t\t\t\ttag_id {\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdescription\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": typeof types.ArticleDocument,
"\n\t\tquery RecentArticle {\n\t\t\tarticle {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\ttitle\n\t\t\t\t\tcover_image {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tviews\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": typeof types.RecentArticleDocument,
"\n\t\tquery Article_translations($slug: String!) {\n\t\t\tarticle_translations(filter: { slug: { _eq: $slug } }) {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\ttags {\n\t\t\t\t\ttag_id {\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdescription\n\t\t\t\tslug\n\t\t\t\tcontent\n\t\t\t\tauthor {\n\t\t\t\t\tfirst_name\n\t\t\t\t\tlast_name\n\t\t\t\t}\n\t\t\t\tpub_date\n\t\t\t\tviews\n\t\t\t\tlanguages_code {\n\t\t\t\t\tcode\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcover_image {\n\t\t\t\t\ttitle\n\t\t\t\t\ttype\n\t\t\t\t\tfilename_disk\n\t\t\t\t}\n\t\t\t\tarticle_id {\n\t\t\t\t\ttranslations {\n\t\t\t\t\t\tslug\n\t\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\t\tcode\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpub_date\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": typeof types.Article_TranslationsDocument,
@ -26,6 +28,8 @@ type Documents = {
"\n\t\tquery Tag {\n\t\t\ttag {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t}\n\t\t}\n\t": typeof types.TagDocument,
};
const documents: Documents = {
"\n\t\tquery LatestAppealByClientIpQuery($client_ip: String!, $limit: Int!) {\n\t\t\tappeal(filter: { client_ip: { _eq: $client_ip } }, sort: [\"-date_created\"], limit: $limit) {\n\t\t\t\tclient_ip\n\t\t\t\tdate_created\n\t\t\t}\n\t\t}\n\t": types.LatestAppealByClientIpQueryDocument,
"\n\t\tmutation CreateAppealQuery(\n\t\t\t$name: String!\n\t\t\t$email: String!\n\t\t\t$subject: String!\n\t\t\t$message: String!\n\t\t\t$client_ip: String!\n\t\t) {\n\t\t\tcreate_appeal_item(\n\t\t\t\tdata: { name: $name, email: $email, subject: $subject, message: $message, client_ip: $client_ip }\n\t\t\t) {\n\t\t\t\tid\n\t\t\t}\n\t\t}\n\t": types.CreateAppealQueryDocument,
"\n\t\tquery Article {\n\t\t\tarticle {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\tid\n\t\t\t\t\ttitle\n\t\t\t\t\ttags {\n\t\t\t\t\t\ttag_id {\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdescription\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": types.ArticleDocument,
"\n\t\tquery RecentArticle {\n\t\t\tarticle {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\ttitle\n\t\t\t\t\tcover_image {\n\t\t\t\t\t\tid\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tviews\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": types.RecentArticleDocument,
"\n\t\tquery Article_translations($slug: String!) {\n\t\t\tarticle_translations(filter: { slug: { _eq: $slug } }) {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\ttags {\n\t\t\t\t\ttag_id {\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdescription\n\t\t\t\tslug\n\t\t\t\tcontent\n\t\t\t\tauthor {\n\t\t\t\t\tfirst_name\n\t\t\t\t\tlast_name\n\t\t\t\t}\n\t\t\t\tpub_date\n\t\t\t\tviews\n\t\t\t\tlanguages_code {\n\t\t\t\t\tcode\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcover_image {\n\t\t\t\t\ttitle\n\t\t\t\t\ttype\n\t\t\t\t\tfilename_disk\n\t\t\t\t}\n\t\t\t\tarticle_id {\n\t\t\t\t\ttranslations {\n\t\t\t\t\t\tslug\n\t\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\t\tcode\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpub_date\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": types.Article_TranslationsDocument,
@ -37,6 +41,14 @@ const documents: Documents = {
"\n\t\tquery Tag {\n\t\t\ttag {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t}\n\t\t}\n\t": types.TagDocument,
};
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery LatestAppealByClientIpQuery($client_ip: String!, $limit: Int!) {\n\t\t\tappeal(filter: { client_ip: { _eq: $client_ip } }, sort: [\"-date_created\"], limit: $limit) {\n\t\t\t\tclient_ip\n\t\t\t\tdate_created\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').LatestAppealByClientIpQueryDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tmutation CreateAppealQuery(\n\t\t\t$name: String!\n\t\t\t$email: String!\n\t\t\t$subject: String!\n\t\t\t$message: String!\n\t\t\t$client_ip: String!\n\t\t) {\n\t\t\tcreate_appeal_item(\n\t\t\t\tdata: { name: $name, email: $email, subject: $subject, message: $message, client_ip: $client_ip }\n\t\t\t) {\n\t\t\t\tid\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').CreateAppealQueryDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@ -4517,6 +4517,25 @@ export type Version_Tag = {
title?: Maybe<Scalars['String']['output']>;
};
export type LatestAppealByClientIpQueryQueryVariables = Exact<{
client_ip: Scalars['String']['input'];
limit: Scalars['Int']['input'];
}>;
export type LatestAppealByClientIpQueryQuery = { __typename?: 'Query', appeal: Array<{ __typename?: 'appeal', client_ip: string, date_created?: any | null }> };
export type CreateAppealQueryMutationVariables = Exact<{
name: Scalars['String']['input'];
email: Scalars['String']['input'];
subject: Scalars['String']['input'];
message: Scalars['String']['input'];
client_ip: Scalars['String']['input'];
}>;
export type CreateAppealQueryMutation = { __typename?: 'Mutation', create_appeal_item?: { __typename?: 'appeal', id: string } | null };
export type ArticleQueryVariables = Exact<{ [key: string]: never; }>;
@ -4590,6 +4609,27 @@ export class TypedDocumentString<TResult, TVariables>
}
}
export const LatestAppealByClientIpQueryDocument = new TypedDocumentString(`
query LatestAppealByClientIpQuery($client_ip: String!, $limit: Int!) {
appeal(
filter: {client_ip: {_eq: $client_ip}}
sort: ["-date_created"]
limit: $limit
) {
client_ip
date_created
}
}
`) as unknown as TypedDocumentString<LatestAppealByClientIpQueryQuery, LatestAppealByClientIpQueryQueryVariables>;
export const CreateAppealQueryDocument = new TypedDocumentString(`
mutation CreateAppealQuery($name: String!, $email: String!, $subject: String!, $message: String!, $client_ip: String!) {
create_appeal_item(
data: {name: $name, email: $email, subject: $subject, message: $message, client_ip: $client_ip}
) {
id
}
}
`) as unknown as TypedDocumentString<CreateAppealQueryMutation, CreateAppealQueryMutationVariables>;
export const ArticleDocument = new TypedDocumentString(`
query Article {
article {

View File

@ -17,8 +17,7 @@ export default {
/* Page descriptions */
"pageDescription.articles":
"Technical articles, tutorials and various reference materials related to IT topics can be found here.",
"pageDescription.projects":
"A few of my main projects that have been developed and published in open access.",
"pageDescription.projects": "A few of my main projects that have been developed and published in open access.",
"pageDescription.contacts": "Use the feedback form or write to me on social networks.",
/* Navigation */
"navigation.articles": "Articles",
@ -57,4 +56,9 @@ export default {
/* Footer */
"footer.rssFeed": "RSS Feed",
"footer.developed": "Developed",
/* Submitted page */
"submitted.heading": "Message sent",
"submitted.description": "Your message has been sent, thank you.",
"submitted.error": "Error while sending a message. Try again later.",
"submitted.limit": "Send your message later.",
};

View File

@ -17,10 +17,8 @@ export default {
/* Page descriptions */
"pageDescription.articles":
"Технические статьи, туториалы и различные справочные материалы которые связаны с IT тематикой находятся здесь.",
"pageDescription.projects":
"Несколько моих основных проектов которые были разработаны и выложены в открытый доступ.",
"pageDescription.contacts":
"Воспользуйтесь формой обратной связи или напишите мне в социальные сети.",
"pageDescription.projects": "Несколько моих основных проектов которые были разработаны и выложены в открытый доступ.",
"pageDescription.contacts": "Воспользуйтесь формой обратной связи или напишите мне в социальные сети.",
/* Navigation */
"navigation.articles": "Статьи",
"navigation.projects": "Проекты",
@ -58,4 +56,9 @@ export default {
/* Footer */
"footer.rssFeed": "RSS Лента",
"footer.developed": "Разработка",
/* Submitted page */
"submitted.heading": "Сообщение отправлено",
"submitted.description": "Ваше сообщение отправлено, спасибо.",
"submitted.error": "Ошибка отправки сообщения. Повторите позже.",
"submitted.limit": "Отправьте ваше сообщение позже.",
};

View File

@ -1,6 +0,0 @@
import { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, clientAddress }) => {
const data = await request.formData();
return new Response("done.", { status: 200 });
};

43
src/queries/appeal.ts Normal file
View File

@ -0,0 +1,43 @@
import { graphql } from "../graphql";
import execute from "../graphql/execute";
export async function getLatestAppealsByClientIp(client_ip: string, limit: number = 5) {
const LatestAppealByClientIpQuery = graphql(`
query LatestAppealByClientIpQuery($client_ip: String!, $limit: Int!) {
appeal(filter: { client_ip: { _eq: $client_ip } }, sort: ["-date_created"], limit: $limit) {
client_ip
date_created
}
}
`);
const result = await execute(LatestAppealByClientIpQuery, { client_ip, limit });
if (!result.data) return [];
return result.data.appeal;
}
type CreateAppealParams = { name: string; email: string; subject: string; message: string; client_ip: string };
export async function createAppeal({ name, email, subject, message, client_ip }: CreateAppealParams) {
const CreateAppealQuery = graphql(`
mutation CreateAppealQuery(
$name: String!
$email: String!
$subject: String!
$message: String!
$client_ip: String!
) {
create_appeal_item(
data: { name: $name, email: $email, subject: $subject, message: $message, client_ip: $client_ip }
) {
id
}
}
`);
const result = await execute(CreateAppealQuery, { name, email, subject, message, client_ip });
return result.data?.create_appeal_item;
}

View File

@ -2,3 +2,4 @@ export * from "./tag";
export * from "./article";
export * from "./project";
export * from "./global";
export * from "./appeal";

View File

@ -3,21 +3,25 @@ import PageLayout from "@layouts/default.astro";
import PageHeading from "@components/PageHeading.astro";
import ContactForm from "@components/ContactForm.astro";
import ContactsList from "@components/ContactsList.astro";
import SubmittedTemplate from "@templates/Submitted.astro";
import { actions } from "astro:actions";
import { getLanguageFromURL, useTranslation } from "@i18n/utils";
const t = useTranslation(Astro);
const lang = getLanguageFromURL(Astro.url.pathname);
const contactsFormSubmitResult = Astro.getActionResult(actions.appeal);
---
<PageLayout
noIndex
lang={lang}
title={t("pageTitle.contacts")}
description={t("pageDescription.contacts")}
>
{
!contactsFormSubmitResult || contactsFormSubmitResult.error ? (
<PageLayout noIndex lang={lang} title={t("pageTitle.contacts")} description={t("pageDescription.contacts")}>
<PageHeading heading={t("pageTitle.contacts")} description={t("pageDescription.contacts")} />
<section>
<ContactForm />
<ContactsList />
</section>
</PageLayout>
</PageLayout>
) : (
<SubmittedTemplate />
)
}

View File

@ -0,0 +1,13 @@
---
import PageLayout from "@layouts/default.astro";
import { getLanguageFromURL } from "@i18n/utils";
import UIString from "@root/components/UIString.astro";
const lang = getLanguageFromURL(Astro.url.pathname);
---
<PageLayout noIndex lang={lang}>
<div class="container">
<h1 style="font-size:var(--font-size-5xl);"><UIString key={"submitted.heading"} /></h1>
<p><UIString key={"submitted.description"} /></p>
</div>
</PageLayout>