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=
DIRECTUS_URL_GRAPHQL=
DIRECTUS_TOKEN= DIRECTUS_TOKEN=

View File

@ -12,12 +12,21 @@ export default defineConfig({
site: "https://yarkov.tech", site: "https://yarkov.tech",
compressHTML: true, compressHTML: true,
output: "server", output: "server",
server: {
port: 9966,
allowedHosts: ["showcase.yarkov.tech"],
},
adapter: node({ adapter: node({
mode: "standalone", mode: "standalone",
}), }),
env: { env: {
schema: { schema: {
DIRECTUS_URL: envField.string({ context: "server", access: "secret", default: "" }), 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: "" }), DIRECTUS_TOKEN: envField.string({ context: "server", access: "secret", default: "" }),
}, },
}, },

View File

@ -11,11 +11,12 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev --host",
"build": "astro check && astro build", "build": "astro check && astro build",
"start": "node --env-file=.env ./dist/server/entry.mjs", "start": "node --env-file=.env ./dist/server/entry.mjs",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"telemetry-disable": "astro telemetry disable",
"codegen": "graphql-codegen --require dotenv/config --config codegen.ts --watch" "codegen": "graphql-codegen --require dotenv/config --config codegen.ts --watch"
}, },
"dependencies": { "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 Button from "@components/Button.astro";
import UIString from "./UIString.astro"; import UIString from "./UIString.astro";
import { useTranslation } from "@i18n/utils"; import { useTranslation } from "@i18n/utils";
import { actions } from "astro:actions";
const t = useTranslation(Astro); 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 class="flex">
<div> <div>
<input name="name" placeholder={t("input.nameField")} type="text" maxlength="20" autocomplete="off" required /> <input name="name" placeholder={t("input.nameField")} type="text" maxlength="20" autocomplete="off" required />
</div> </div>
<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>
</div> </div>
<input name="subject" placeholder={t("input.subjectField")} type="text" autocomplete="off" required /> <input name="subject" placeholder={t("input.subjectField")} type="text" autocomplete="off" required />
@ -25,11 +28,19 @@ const t = useTranslation(Astro);
rows={10} rows={10}
required></textarea> required></textarea>
<div class="footer"> <div class="footer">
{
formResult?.error && (
<span>
<UIString key={formResult.error.status === 429 ? "submitted.limit" : "submitted.error"} />
</span>
)
}
<Button type={"submit"}> <Button type={"submit"}>
<UIString key={"btn.submit"} /> <UIString key={"btn.submit"} />
</Button> </Button>
</div> </div>
</form> </form>
</>
<style lang="scss"> <style lang="scss">
form { form {
@ -48,7 +59,10 @@ const t = useTranslation(Astro);
border: 1px solid var(--color-error); border: 1px solid var(--color-error);
} }
& .footer { & .footer {
text-align: right; display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
} }
} }
@media screen and (max-width: 560px) { @media screen and (max-width: 560px) {

View File

@ -1,12 +1,12 @@
import type { ExecutionResult } from "graphql"; import type { ExecutionResult } from "graphql";
import type { TypedDocumentString } 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>( export default async function execute<TResult, TVariables>(
query: TypedDocumentString<TResult, TVariables>, query: TypedDocumentString<TResult, TVariables>,
...[variables]: TVariables extends Record<string, never> ? [] : [TVariables] ...[variables]: TVariables extends Record<string, never> ? [] : [TVariables]
) { ) {
const response = await fetch(DIRECTUS_URL + "/graphql", { const response = await fetch(DIRECTUS_URL_GRAPHQL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "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 * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/ */
type Documents = { 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 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 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, "\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, "\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 = { 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 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 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, "\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, "\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. * 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']>; 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; }>; 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(` export const ArticleDocument = new TypedDocumentString(`
query Article { query Article {
article { article {

View File

@ -17,8 +17,7 @@ export default {
/* Page descriptions */ /* Page descriptions */
"pageDescription.articles": "pageDescription.articles":
"Technical articles, tutorials and various reference materials related to IT topics can be found here.", "Technical articles, tutorials and various reference materials related to IT topics can be found here.",
"pageDescription.projects": "pageDescription.projects": "A few of my main projects that have been developed and published in open access.",
"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.", "pageDescription.contacts": "Use the feedback form or write to me on social networks.",
/* Navigation */ /* Navigation */
"navigation.articles": "Articles", "navigation.articles": "Articles",
@ -57,4 +56,9 @@ export default {
/* Footer */ /* Footer */
"footer.rssFeed": "RSS Feed", "footer.rssFeed": "RSS Feed",
"footer.developed": "Developed", "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 */ /* Page descriptions */
"pageDescription.articles": "pageDescription.articles":
"Технические статьи, туториалы и различные справочные материалы которые связаны с IT тематикой находятся здесь.", "Технические статьи, туториалы и различные справочные материалы которые связаны с IT тематикой находятся здесь.",
"pageDescription.projects": "pageDescription.projects": "Несколько моих основных проектов которые были разработаны и выложены в открытый доступ.",
"Несколько моих основных проектов которые были разработаны и выложены в открытый доступ.", "pageDescription.contacts": "Воспользуйтесь формой обратной связи или напишите мне в социальные сети.",
"pageDescription.contacts":
"Воспользуйтесь формой обратной связи или напишите мне в социальные сети.",
/* Navigation */ /* Navigation */
"navigation.articles": "Статьи", "navigation.articles": "Статьи",
"navigation.projects": "Проекты", "navigation.projects": "Проекты",
@ -58,4 +56,9 @@ export default {
/* Footer */ /* Footer */
"footer.rssFeed": "RSS Лента", "footer.rssFeed": "RSS Лента",
"footer.developed": "Разработка", "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 "./article";
export * from "./project"; export * from "./project";
export * from "./global"; 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 PageHeading from "@components/PageHeading.astro";
import ContactForm from "@components/ContactForm.astro"; import ContactForm from "@components/ContactForm.astro";
import ContactsList from "@components/ContactsList.astro"; import ContactsList from "@components/ContactsList.astro";
import SubmittedTemplate from "@templates/Submitted.astro";
import { actions } from "astro:actions";
import { getLanguageFromURL, useTranslation } from "@i18n/utils"; import { getLanguageFromURL, useTranslation } from "@i18n/utils";
const t = useTranslation(Astro); const t = useTranslation(Astro);
const lang = getLanguageFromURL(Astro.url.pathname); const lang = getLanguageFromURL(Astro.url.pathname);
const contactsFormSubmitResult = Astro.getActionResult(actions.appeal);
--- ---
<PageLayout {
noIndex !contactsFormSubmitResult || contactsFormSubmitResult.error ? (
lang={lang} <PageLayout noIndex lang={lang} title={t("pageTitle.contacts")} description={t("pageDescription.contacts")}>
title={t("pageTitle.contacts")}
description={t("pageDescription.contacts")}
>
<PageHeading heading={t("pageTitle.contacts")} description={t("pageDescription.contacts")} /> <PageHeading heading={t("pageTitle.contacts")} description={t("pageDescription.contacts")} />
<section> <section>
<ContactForm /> <ContactForm />
<ContactsList /> <ContactsList />
</section> </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>