feat: contact form submission

This commit is contained in:
Sergey Yarkov 2022-08-05 21:45:03 +03:00
parent 37cb82ccd5
commit b808437567
28 changed files with 387 additions and 53 deletions

View File

@ -1,7 +1,7 @@
{
"printWidth": 150,
"semi": true,
"singleQuote": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": true

View File

@ -1,10 +1,13 @@
.btn {
cursor: pointer;
display: inline-flex;
column-gap: 7px;
align-items: center;
font-family: var(--font-family-base);
font-weight: 500;
font-size: var(--font-size-md);
color: var(--font-color);
padding: 4px 16px;
padding: 5px 16px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
background: none;
@ -12,4 +15,14 @@
&:hover {
border: 1px solid var(--border-color-hover);
}
&[disabled] {
opacity: 0.5;
&:hover {
border: 1px solid var(--border-color);
}
}
&[data-loading="true"] {
user-select: none;
cursor: not-allowed;
}
}

View File

@ -1,15 +1,20 @@
import type { ParentComponent, JSX } from 'solid-js';
import type { ParentComponent, JSX } from "solid-js";
import { splitProps } from 'solid-js';
import './Button.scss';
import { splitProps } from "solid-js";
import Spinner from "@components/Spinner";
import "./Button.scss";
type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement>;
type ButtonProps = {
isLoading?: boolean | undefined;
loadingText?: string | undefined;
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>;
const Button: ParentComponent<ButtonProps> = (props) => {
const [local, attrs] = splitProps(props, ['children']);
const [local, attrs] = splitProps(props, ["children", "isLoading", "loadingText"]);
return (
<button class={`btn`} {...attrs}>
{local.children}
<button disabled={local.isLoading} class={`btn`} data-loading={local.isLoading} {...attrs}>
{local.isLoading && <Spinner />}
{local.isLoading ? local.loadingText || "Loading..." : local.children}
</button>
);
};

View File

@ -4,19 +4,42 @@
row-gap: 7px;
margin-bottom: 3.125rem;
& div:first-of-type {
display: flex;
column-gap: 10px;
}
& div:last-of-type {
& .form-footer {
text-align: center;
margin-top: 15px;
}
& .form-result {
font-style: italic;
color: var(--font-color-gray);
}
& .error-message {
margin: 2px 0 3px 0;
display: block;
color: var(--color-error);
font-size: var(--font-size-sm);
user-select: none;
&::before {
content: "*";
}
}
& .flex {
column-gap: 10px;
& div {
width: 100%;
}
}
& input.error-input,
& textarea.error-input {
border: 1px solid var(--color-error);
}
}
@media screen and (max-width: 560px) {
.contact-form div:first-of-type {
.contact-form .flex {
flex-direction: column;
row-gap: 7px;
}

View File

@ -1,26 +1,115 @@
import type { Component } from 'solid-js';
import type { Component, JSX } from "solid-js";
import Button from '@components/Button';
import './ContactForm.scss';
import { createSignal } from "solid-js";
import { createStore } from "solid-js/store";
import { useForm } from "@hooks/index";
import { encode } from "@root/utils/url";
import Button from "@components/Button";
import { validateEmail } from "./validations";
import Submitted from "./Submitted";
import Error from "./Error";
import "./ContactForm.scss";
type InputFieldsType = {
name: string;
email: string;
subject: string;
message: string;
};
const ErrorMessage: Component<{ message: string }> = (props) => <span class="error-message">{props.message}</span>;
const ContactForm: Component = () => {
const { formSubmit, validate, errors } = useForm({ errorClass: "error-input" });
const [isSubmitted, setIsSubmitted] = createSignal<boolean>(false);
const [isLoading, setIsLoading] = createSignal<boolean>(false);
const [isError, setIsError] = createSignal<boolean>(false);
const [state, setState] = createStore<ContactFieldsType>({ name: "", email: "", subject: "", message: "", "bot-field": "" });
const reset = (form: HTMLFormElement) => {
for (const key in state) {
form.elements[key].value = "";
setState({ [key]: "" });
}
};
const sendMessage = async (form: HTMLFormElement) => {
try {
setIsLoading(true);
fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: encode({
"form-name": "contact",
...state,
}),
});
} catch (error) {
setIsError(true);
console.error(error);
} finally {
setIsLoading(false);
setIsSubmitted(true);
reset(form);
}
};
const onChange: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
const key = e.currentTarget.name;
const value = e.currentTarget.value;
if (!Object.hasOwn(state, key)) return;
setState({ [key]: value });
};
return (
<form class="contact-form" name="contact" method="post" data-netlify="true">
<div>
<input name="name" placeholder="Ваше имя" type="text" />
<input name="email" placeholder="example@email.com" type="text" />
<form use:formSubmit={sendMessage} class="contact-form" name="contact" method="post" data-netlify="true">
<div hidden>
Don't fill this out if you're human
<input onInput={onChange} name="bot-field" />
</div>
<input name="subject" placeholder="Тема сообщения" type="text" />
<textarea name="message" placeholder="Ваше сообщение..." id="message" cols={30} rows={10} />
<div class="flex">
<div>
<Button type="submit">Отправить</Button>
<input onInput={onChange} use:validate name="name" placeholder="Ваше имя" type="text" maxLength="20" autocomplete="off" required />
{errors.name && <ErrorMessage message={errors.name} />}
</div>
<div>
<input
onInput={onChange}
use:validate={[validateEmail]}
name="email"
placeholder="example@email.com"
autocomplete="off"
type="text"
required
/>
{errors.email && <ErrorMessage message={errors.email} />}
</div>
</div>
<input
value={state.subject}
onInput={onChange}
use:validate
name="subject"
placeholder="Тема сообщения"
type="text"
autocomplete="off"
required
/>
{errors.subject && <ErrorMessage message={errors.subject} />}
<textarea
onInput={onChange}
use:validate
name="message"
placeholder="Ваше сообщение..."
id="message"
autocomplete="off"
cols={30}
rows={10}
required
/>
{errors.message && <ErrorMessage message={errors.message} />}
<div class="form-footer">
{!isError() && !isSubmitted() && (
<Button isLoading={isLoading()} loadingText="Отправка..." type="submit">
Отправить
</Button>
)}
{!isError() && isSubmitted() && <Submitted />}
{isError() && isSubmitted() && <Error />}
</div>
</form>
);

View File

@ -0,0 +1,7 @@
import type { Component } from "solid-js";
const Submitted: Component = () => {
return <div class="form-result">&#10060; При отправке произошла ошибка. Повторите запрос позже.</div>;
};
export default Submitted;

View File

@ -0,0 +1,7 @@
import type { Component } from "solid-js";
const Submitted: Component = () => {
return <div class="form-result">&#10004;&#65039; Сообщение было успешно отправлено!</div>;
};
export default Submitted;

View File

@ -0,0 +1,3 @@
import { isEmail } from "@utils/string";
export const validateEmail = (field: HTMLInputElement) => (isEmail(field.value) ? false : "Заполните поле в формате e-mail.");

View File

@ -0,0 +1,26 @@
.spinner {
--spinner-size: 1em;
display: inline-block;
border-top: 1px solid var(--font-color);
border-right: 1px solid var(--font-color);
border-bottom-style: solid;
border-left-style: solid;
border-radius: 99999px;
border-bottom-width: 1px;
border-left-width: 1px;
border-bottom-color: transparent;
border-left-color: transparent;
animation: 0.45s linear 0s infinite normal none running spinner;
width: var(--spinner-size);
height: var(--spinner-size);
}
@keyframes spinner {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,11 @@
import type { Component, JSX } from "solid-js";
import "./Spinner.scss";
type SpinnerProps = JSX.HTMLAttributes<HTMLDivElement>;
const Spinner: Component<SpinnerProps> = (props) => {
return <div class="spinner" {...props} />;
};
export default Spinner;

View File

@ -0,0 +1 @@
export { default } from "./Spinner";

1
src/hooks/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./useForm";

80
src/hooks/useForm.ts Normal file
View File

@ -0,0 +1,80 @@
import type { Accessor } from "solid-js";
import { createStore, SetStoreFunction } from "solid-js/store";
type HTMLElementsType = HTMLInputElement | HTMLTextAreaElement;
type CustomValidatorType = (element: HTMLElementsType) => string | false;
type FieldConfigType = { element: HTMLElementsType; validators: Array<CustomValidatorType> };
type FormSubmitFnType = (ref: HTMLFormElement, accessor: Accessor<(form: HTMLFormElement) => unknown>) => void;
type ValidateFnType = (ref: HTMLElementsType, accessor: Accessor<Array<CustomValidatorType>>) => void;
type СheckValidFnType = (config: FieldConfigType, setErrors: SetStoreFunction<Record<string, unknown>>, errorClass: string) => void;
const checkValid: СheckValidFnType = (fieldConfig, setErrors, errorClass) => {
const { element, validators = [] } = fieldConfig;
element.setCustomValidity("");
element.checkValidity();
let message = element.validationMessage;
/* Run custom validators */
if (Array.isArray(validators)) {
if (!message) {
for (const validator of validators) {
if (typeof validator === "function") {
const text = validator(element);
if (text) {
element.setCustomValidity(text);
break;
}
}
}
message = element.validationMessage;
}
}
/* Set errors if message exists and class to input element */
if (message) {
errorClass && element.classList.toggle(errorClass, true);
setErrors({ [element.name]: message });
}
};
export const useForm = ({ errorClass }) => {
const [errors, setErrors] = createStore<{ [key: string]: string | undefined }>({});
const fields = {};
const validate: ValidateFnType = (ref, accessor) => {
const validators = accessor();
let config: FieldConfigType;
fields[ref.name] = config = { element: ref, validators };
ref.onblur = () => checkValid(config, setErrors, errorClass);
ref.oninput = () => {
if (!errors[ref.name]) return;
setErrors({ [ref.name]: undefined });
errorClass && ref.classList.toggle(errorClass, false);
};
};
const formSubmit: FormSubmitFnType = (ref, accessor) => {
const callback = accessor();
ref.setAttribute("novalidate", "");
ref.onsubmit = (e) => {
e.preventDefault();
let isError = false;
/* Validate all fields of form */
for (const key in fields) {
const field = fields[key] as FieldConfigType;
checkValid(field, setErrors, errorClass);
if (!isError && field.element.validationMessage) {
field.element.focus();
isError = true;
}
}
if (typeof callback === "function") !isError && callback(ref);
};
};
return { validate, formSubmit, errors };
};

21
src/i18n/en/ui.ts Normal file
View File

@ -0,0 +1,21 @@
export default {
"site.title": "yarkov.tech",
/* Navigation */
"navigation.articles": "Articles",
"navigation.projects": "Projects",
"navigation.about": "About",
"navigation.contacts": "Contacts",
/* Splash page */
"splash.headline": "Hello, I'am Sergey",
"splash.subheadline": "Fullstack web-developer & freelancer",
"splash.description":
"I develop websites and web applications of varying complexity, as well as publish various articles and tutorials on various IT topics.",
"splash.about": "about",
"splash.contact": "write me",
/* Recent articles */
"recentArticles.heading": "recent publications",
"recentArticles.all": "see all",
/* 404 page */
"404.heading": "404",
"404.description": "The page you are trying to find does not exist.",
};

21
src/i18n/ru/ui.ts Normal file
View File

@ -0,0 +1,21 @@
export default {
"site.title": "yarkov.tech",
/* Navigation */
"navigation.articles": "Статьи",
"navigation.projects": "Проекты",
"navigation.about": "Обо мне",
"navigation.contacts": "Контакты",
/* Splash page */
"splash.headline": "Hello, I'am Sergey",
"splash.subheadline": "Fullstack web-developer & freelancer",
"splash.description":
"Я занимаюсь разработкой веб-сайтов и веб-приложений разной сложности, а также публикую различные статьи и туториалы на разные IT темы.",
"splash.about": "обо мне",
"splash.contact": "написать мне",
/* Recent articles */
"recentArticles.heading": "недавние публикации",
"recentArticles.all": "смотерть все",
/* 404 page */
"404.heading": "404",
"404.description": "Страницу которую вы пытаетесь найти не существует.",
};

View File

@ -1,5 +1,5 @@
---
import NotFound from '@root/templates/NotFound.astro';
import NotFound from '@templates/NotFound.astro';
---
<NotFound />

View File

@ -1,5 +1,5 @@
---
import About from '@root/templates/About.astro';
import About from '@templates/About.astro';
---
<About />

View File

@ -1,5 +1,5 @@
---
import Articles from '@root/templates/Articles.astro';
import Articles from '@templates/Articles.astro';
---
<Articles />

View File

@ -1,5 +1,5 @@
---
import Contacts from '@root/templates/Contacts.astro';
import Contacts from '@templates/Contacts.astro';
---
<Contacts />

View File

@ -1,5 +1,5 @@
---
import Home from '@root/templates/Home.astro';
import Home from '@templates/Home.astro';
---
<Home />

View File

@ -1,5 +1,5 @@
---
import Projects from '@root/templates/Projects.astro';
import Projects from '@templates/Projects.astro';
---
<Projects />

View File

@ -1,4 +1,4 @@
@import './theme.scss';
@import "./theme.scss";
::selection {
background: var(--color-yellow-100);
@ -13,7 +13,7 @@ body {
min-height: 100vh;
}
body[data-is-mobile-open='true'] {
body[data-is-mobile-open="true"] {
overflow-y: hidden;
}
@ -44,6 +44,10 @@ body {
padding: 0 var(--content-padding);
}
.flex {
display: flex;
}
.list-links {
display: flex;
flex-wrap: wrap;
@ -73,7 +77,7 @@ body {
color: inherit;
border-color: var(--border-color-hover);
}
&[data-active='true'] {
&[data-active="true"] {
background-color: var(--background-btn-link-active);
border-color: var(--border-color-hover);
}
@ -97,6 +101,7 @@ input,
textarea {
-webkit-appearance: none;
font-family: var(--font-family-base);
font-size: 20px;
padding: 8px 15px;
border-radius: var(--border-radius-md);
background-color: var(--background-inputs-color);

View File

@ -1,10 +1,10 @@
:root,
:root[data-theme='light'] {
:root[data-theme="light"] {
color-scheme: light;
/* Fonts */
--font-fallback: -apple-system, BlinkMacSystemFont, Segoe UI, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;
--font-family-base: 'Rubik', var(--font-fallback);
--font-family-base: "Rubik", var(--font-fallback);
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
@ -20,7 +20,7 @@
--font-size-9xl: 8rem;
/* Line heights */
--line-height-normal: 'normal';
--line-height-normal: "normal";
--line-height-none: 1;
--line-height-shorter: 1.25;
--line-height-short: 1.375;
@ -31,6 +31,8 @@
--color-white: #ffffff;
--color-black: #010409;
--color-error: #ff3737;
--color-blue-50: #cae8ff;
--color-blue-100: #a5d6ff;
--color-blue-200: #79c0ff;
@ -109,7 +111,7 @@
--icon-secondary: var(--color-gray-700);
}
:root[data-theme='dark'] {
:root[data-theme="dark"] {
color-scheme: dark;
/* Fonts */
--font-color: var(--color-white);
@ -136,7 +138,7 @@
--icon-filter: invert(100%);
& .header svg,
& .site-content svg:not([data-filter='none']) {
& .site-content svg:not([data-filter="none"]) {
filter: var(--icon-filter);
}
}

View File

@ -1,7 +1,7 @@
---
import PageLayout from '@layouts/default.astro';
import ContactForm from '@components/ContactForm';
import ContactsList from '@components/ContactsList';
import PageLayout from "@layouts/default.astro";
import ContactForm from "@components/ContactForm";
import ContactsList from "@components/ContactsList";
---
<PageLayout title="Контакты" description="Воспользуйтесь формой обратной связи или напишите мне в социальные сети.">

10
src/types/main.d.ts vendored
View File

@ -58,9 +58,17 @@ declare global {
list: Array<{ icon?: string | undefined; name: string }>;
};
type ContactFieldsType = {
name: string;
email: string;
subject: string;
message: string;
"bot-field": string;
};
type ArticleBlockType = {
[year: number]: ArticleType[];
};
type ThemeModeType = 'light' | 'dark';
type ThemeModeType = "light" | "dark";
}

View File

@ -1,3 +1,7 @@
export function removeSlashes(string: string): string {
return string.replaceAll('/', '');
return string.replaceAll("/", "");
}
export function isEmail(email: string): boolean {
return new RegExp(/[a-z0-9]+@[a-z]+\.[a-z]{2,3}/).test(email);
}

5
src/utils/url.ts Normal file
View File

@ -0,0 +1,5 @@
export const encode = (data: object) => {
return Object.keys(data)
.map((key) => encodeURIComponent(key) + "=" + encodeURIComponent(data[key]))
.join("&");
};

View File

@ -13,9 +13,11 @@
"@root/*": ["./src/*"],
"@styles/*": ["./src/styles/*"],
"@components/*": ["./src/components/*"],
"@templates/*": ["./src/templates/*"],
"@layouts/*": ["./src/layouts/*"],
"@utils/*": ["./src/utils/*"],
"@stores/*": ["./src/stores/*"]
"@stores/*": ["./src/stores/*"],
"@hooks/*": ["./src/hooks/*"]
},
"outDir": "./dist",
"typeRoots": ["./node_modules/@types", "./src/types"]