setModal(false)}
30 | />
31 | {children}
32 |
33 | )}
34 | >
35 | );
36 | }
37 |
38 | export default forwardRef(Modal);
39 |
--------------------------------------------------------------------------------
/schemas/form.ts:
--------------------------------------------------------------------------------
1 | import { FormData } from '@/types/form';
2 | import { object, string, addMethod, ObjectSchema, array } from 'yup';
3 |
4 | const getFormSchema = () => {
5 | /* Override the email method, if email isn't required we need to add excludeEmptyString: true */
6 | addMethod(string, 'email', function validateEmail(message: string) {
7 | return this.matches(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i, {
8 | message,
9 | name: 'email',
10 | });
11 | });
12 |
13 | const schema: ObjectSchema
= object({
14 | firstname: string().required('This field is required'),
15 | lastname: string().required('This field is required'),
16 | email: string()
17 | .required('This field is required')
18 | .email('Invalid email address'),
19 | subject: string().required('This field is required'),
20 | choices: array()
21 | .of(string())
22 | .min(1, 'Please select one of these choices'),
23 | question: string().required('Please select one of these answers'),
24 | message: string().required('This field is required'),
25 | });
26 |
27 | return schema;
28 | };
29 |
30 | export const formSchema = getFormSchema();
31 |
--------------------------------------------------------------------------------
/context/transitionContext.tsx:
--------------------------------------------------------------------------------
1 | import gsap from 'gsap';
2 | import {
3 | useState,
4 | createContext,
5 | useContext,
6 | ReactNode,
7 | Dispatch,
8 | SetStateAction,
9 | } from 'react';
10 |
11 | interface TransitionContextType {
12 | timeline: GSAPTimeline | null;
13 | setTimeline: Dispatch>;
14 | resetTimeline: () => void;
15 | }
16 |
17 | const TransitionContext = createContext({
18 | timeline: null,
19 | setTimeline: () => {},
20 | resetTimeline: () => {},
21 | });
22 |
23 | export function TransitionContextProvider({
24 | children,
25 | }: {
26 | children: ReactNode;
27 | }) {
28 | const [timeline, setTimeline] = useState(gsap.timeline({ paused: true }));
29 |
30 | const resetTimeline = () => {
31 | timeline.pause().clear();
32 | };
33 |
34 | const contextValue: TransitionContextType = {
35 | timeline,
36 | setTimeline,
37 | resetTimeline,
38 | };
39 |
40 | return (
41 |
42 | {children}
43 |
44 | );
45 | }
46 |
47 | export default function useTransitionContext(): TransitionContextType {
48 | return useContext(TransitionContext);
49 | }
50 |
--------------------------------------------------------------------------------
/public/static/favicons/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
24 |
--------------------------------------------------------------------------------
/hooks/useUnsavedChanges.ts:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useEffect } from 'react';
3 |
4 | export default function useUnsavedChanges(isDirty: boolean) {
5 | const router = useRouter();
6 |
7 | /* Prompt the user if they try and leave with unsaved changes */
8 | useEffect(() => {
9 | const warningText =
10 | 'You have unsaved changes - are you sure you wish to leave this page?';
11 | const handleWindowClose = (e: BeforeUnloadEvent) => {
12 | if (!isDirty) return;
13 | e.preventDefault();
14 | return (e.returnValue = warningText);
15 | };
16 | const handleBrowseAway = () => {
17 | if (!isDirty) return;
18 | if (window.confirm(warningText)) return;
19 | router.events.emit('routeChangeError');
20 | throw 'routeChange aborted.';
21 | };
22 | window.addEventListener('beforeunload', handleWindowClose);
23 | router.events.on('routeChangeStart', handleBrowseAway);
24 | return () => {
25 | window.removeEventListener('beforeunload', handleWindowClose);
26 | router.events.off('routeChangeStart', handleBrowseAway);
27 | };
28 | // eslint-disable-next-line react-hooks/exhaustive-deps
29 | }, [isDirty]);
30 | }
31 |
--------------------------------------------------------------------------------
/styles/tools/mixins/_button.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Tools / Mixins / Button
3 | ========================================================================== */
4 |
5 | /* Button
6 | ========================================================================== */
7 |
8 | @mixin make-button() {
9 | /*
10 |
11 | Ligth/Dark mode
12 |
13 | All css color variables are in the _document.scss
14 |
15 | --btn-bg-color: var(--primary);
16 | --btn-border-color: var(--primary);
17 | --btn-color: var(--white);
18 | --btn-hover-color: var(--white);
19 | --btn-hover-bg-color: var(--primary-light);
20 | --btn-hover-border-color: var(--primary-light);
21 |
22 | */
23 |
24 | --btn-padding-tb: 8px;
25 | --btn-padding-lr: 20px;
26 |
27 | display: inline-block;
28 | background: var(--btn-bg-color);
29 | border: 1px solid var(--btn-border-color);
30 | color: var(--btn-color);
31 | font-weight: var(--font-bold);
32 | padding: var(--btn-padding-tb) var(--btn-padding-lr);
33 | transition: all 0.35s $ease-in;
34 |
35 | &:hover,
36 | &:focus {
37 | background: var(--btn-hover-bg-color);
38 | border: 1px solid var(--btn-hover-border-color);
39 | color: var(--btn-hover-color);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/styles/tools/mixins/_grid.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Tools / Mixins / Grid
3 | ========================================================================== */
4 |
5 | @use 'sass:math';
6 |
7 | /* Grid
8 | ========================================================================== */
9 |
10 | @mixin make-row() {
11 | display: flex;
12 | flex-wrap: wrap;
13 | margin-right: calc(-1 * var(--grid-gutter-width));
14 | margin-left: calc(-1 * var(--grid-gutter-width));
15 | }
16 |
17 | /*
18 | * Prevent columns from becoming too narrow when at smaller grid tiers by
19 | * always setting `width: 100%;`. This works because we use `flex` values
20 | * later on to override this initial width.
21 | * 1. Prevent collapsing
22 | */
23 | @mixin make-col-ready() {
24 | width: 100%;
25 | min-height: 1px; // 1
26 | padding-right: var(--grid-gutter-width);
27 | padding-left: var(--grid-gutter-width);
28 | }
29 |
30 | /*
31 | * Add a `max-width` to ensure content within each column does not blow out
32 | * the width of the column. Applies to IE10+ and Firefox. Chrome and Safari
33 | * do not appear to require this.
34 | */
35 | @mixin make-col($size, $columns: $grid-columns) {
36 | flex: 0 0 percentage(math.div($size, $columns));
37 | max-width: percentage(math.div($size, $columns));
38 | }
39 |
--------------------------------------------------------------------------------
/components/form/FormRadioList.tsx:
--------------------------------------------------------------------------------
1 | import { RadioList } from '@/types/form/elements';
2 | import FormRadio from './FormRadio';
3 | import classNames from 'classnames';
4 | import { slugify } from '@/utils/string';
5 |
6 | export default function FormRadioList({
7 | title,
8 | items,
9 | className,
10 | htmlFor,
11 | register,
12 | errors,
13 | }: RadioList) {
14 | return (
15 |
16 |
{title}
17 |
24 | {items.map((item) => (
25 |
34 | ))}
35 |
36 | {errors?.message && (
37 |
38 | )}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/components/form/FormCheckboxList.tsx:
--------------------------------------------------------------------------------
1 | import { CheckboxList } from '@/types/form/elements';
2 | import FormCheckbox from './FormCheckbox';
3 | import classNames from 'classnames';
4 | import { slugify } from '@/utils/string';
5 |
6 | export default function FormCheckboxList({
7 | title,
8 | items,
9 | className,
10 | htmlFor,
11 | register,
12 | errors,
13 | }: CheckboxList) {
14 | return (
15 |
16 |
{title}
17 |
24 | {items.map((item) => (
25 |
34 | ))}
35 |
36 | {errors?.message && (
37 |
38 | )}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/utils/recaptcha.ts:
--------------------------------------------------------------------------------
1 | import { NextApiResponse } from 'next';
2 |
3 | /**
4 | * Validates recaptcha and interprets the score
5 | *
6 | * https://developers.google.com/recaptcha/docs/v3
7 | *
8 | * @param {string} token recaptcha token
9 | * @param {Object} res server response object
10 | * @returns {boolean} true or false
11 | */
12 | export const validateRecaptcha = async (
13 | token: string,
14 | res: NextApiResponse,
15 | ): Promise => {
16 | try {
17 | const response = await fetch(
18 | 'https://www.google.com/recaptcha/api/siteverify',
19 | {
20 | method: 'POST',
21 | headers: {
22 | 'Content-Type': 'application/x-www-form-urlencoded',
23 | },
24 | body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
25 | },
26 | );
27 |
28 | const result = await response.json();
29 |
30 | if (result?.success) {
31 | if (result?.score >= 0.5) {
32 | return true;
33 | }
34 | throw new Error(`ReCaptcha validation failed`);
35 | }
36 | throw new Error(
37 | `Error validating captcha: ${result['error-codes'][0]}`,
38 | );
39 |
40 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
41 | } catch (err: any) {
42 | res.status(422).json({ message: err.message });
43 | return false;
44 | }
45 | };
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-typescript-starter",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "format:write": "prettier --config ./prettierrc.json --write \"**/*.{css,scss,js,json,jsx,ts,tsx}\"",
9 | "format": "prettier \"**/*.{css,scss,js,json,jsx,ts,tsx}\"",
10 | "start": "next start",
11 | "lint": "next lint"
12 | },
13 | "dependencies": {
14 | "@hookform/resolvers": "^3.1.0",
15 | "@sendgrid/mail": "^7.7.0",
16 | "@types/formidable": "^3.4.5",
17 | "@types/node": "18.16.2",
18 | "@types/react": "^18.2.70",
19 | "@types/react-dom": "^18.2.22",
20 | "classnames": "^2.3.2",
21 | "eslint": "8.39.0",
22 | "eslint-config-next": "^14.1.4",
23 | "formidable": "^3.5.1",
24 | "gsap": "npm:gsap-trial",
25 | "next": "^14.1.4",
26 | "next-themes": "^0.2.1",
27 | "react": "^18.2.0",
28 | "react-dom": "^18.2.0",
29 | "react-google-recaptcha-v3": "^1.10.1",
30 | "react-hook-form": "^7.43.9",
31 | "react-toastify": "^9.1.2",
32 | "react-toggle-dark-mode": "^1.1.1",
33 | "typescript": "5.0.4",
34 | "yup": "^1.1.1"
35 | },
36 | "devDependencies": {
37 | "@typescript-eslint/eslint-plugin": "^5.60.1",
38 | "eslint-config-prettier": "^8.8.0",
39 | "prettier": "^2.8.8",
40 | "sass": "^1.62.1"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/components/gsap/TranslateInOut.tsx:
--------------------------------------------------------------------------------
1 | import { Translate } from '@/types/animations';
2 | import AnimateInOut from './AnimateInOut';
3 |
4 | export default function TranslateInOut({
5 | children,
6 | fade = true,
7 | durationIn = 0.5,
8 | durationOut = 0.25,
9 | delay = 0,
10 | delayOut = 0,
11 | ease = 'power4.out',
12 | easeOut = 'power4.out',
13 | x = '0px',
14 | y = '0px',
15 | xTo = 0,
16 | yTo = 0,
17 | transformOrigin,
18 | outro,
19 | skipOutro,
20 | watch,
21 | start = 'top bottom',
22 | end = 'bottom top',
23 | scrub = false,
24 | markers,
25 | }: Translate) {
26 | return (
27 |
53 | {children}
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/components/form/FormTextarea.tsx:
--------------------------------------------------------------------------------
1 | import { Textarea } from '@/types/form/elements';
2 | import styles from '../../styles/modules/FormTextarea.module.scss';
3 | import classNames from 'classnames';
4 |
5 | export default function FormTextarea({
6 | htmlFor,
7 | label,
8 | id,
9 | placeholder = ' ',
10 | required,
11 | className,
12 | wrapperClassName,
13 | register,
14 | errors,
15 | }: Textarea) {
16 | return (
17 |
18 |
28 |
34 | {label && htmlFor && (
35 |
39 | )}
40 |
41 |
42 | {errors?.message && (
43 |
44 | )}
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/components/gsap/ScaleInOut.tsx:
--------------------------------------------------------------------------------
1 | import { Scale } from '@/types/animations';
2 | import AnimateInOut from './AnimateInOut';
3 |
4 | export default function ScaleInOut({
5 | children,
6 | fade = true,
7 | durationIn = 0.5,
8 | durationOut = 0.25,
9 | delay = 0,
10 | delayOut = 0,
11 | ease = 'power4.out',
12 | easeOut = 'power4.out',
13 | scale = '0, 0',
14 | scaleTo = '1, 1',
15 | x = '0px',
16 | y = '0px',
17 | xTo = 0,
18 | yTo = 0,
19 | transformOrigin,
20 | outro,
21 | skipOutro,
22 | watch,
23 | start = 'top bottom',
24 | end = 'bottom top',
25 | scrub = false,
26 | markers,
27 | }: Scale) {
28 | return (
29 |
55 | {children}
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/styles/utilities/_helpers.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Utilities / Helpers
3 | ========================================================================== */
4 |
5 | /* Utilities
6 | ========================================================================== */
7 |
8 | .u-sr-only {
9 | position: absolute;
10 | top: -9999px;
11 | left: -9999px;
12 | overflow: hidden;
13 | width: 1px;
14 | height: 1px;
15 | }
16 |
17 | /* Layout
18 | ========================================================================== */
19 |
20 | .u-overflow--hidden {
21 | overflow: hidden;
22 | }
23 |
24 | /* Images
25 | ========================================================================== */
26 |
27 | .o-lazy {
28 | height: auto;
29 | opacity: 0;
30 | transition: opacity 0.3s $decel-curve;
31 | width: 100%;
32 |
33 | &.is-loaded {
34 | opacity: 1;
35 | }
36 | }
37 |
38 | /* Decorative
39 | ========================================================================== */
40 |
41 | .u-box {
42 | border-radius: 25px;
43 | background: var(--gray-100);
44 | box-shadow: 20px 20px 60px var(--gray-400), -20px -20px 60px var(--white);
45 | }
46 |
47 | .u-box--thin {
48 | border-radius: 22px;
49 | background: var(--gray-100);
50 | box-shadow: 15px 15px 45px var(--gray-400), -15px -15px 45px var(--white);
51 | }
52 |
53 | .u-box--thinner {
54 | border-radius: 20px;
55 | background: var(--gray-100);
56 | box-shadow: 12px 12px 35px var(--gray-400), -12px -12px 35px var(--white);
57 | }
58 |
--------------------------------------------------------------------------------
/hooks/useScrollbar.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect';
3 |
4 | interface Scrollbar {
5 | scrollY: number;
6 | scrollX: number;
7 | directionY: number;
8 | directionX: number;
9 | }
10 |
11 | export default function useScrollbar(): Scrollbar {
12 | const [scrollbar, setScrollbar] = useState({
13 | scrollY: 0,
14 | scrollX: 0,
15 | directionY: -1,
16 | directionX: -1,
17 | });
18 |
19 | const updateScrollbar = useCallback(() => {
20 | setScrollbar((prevState) => {
21 | const prevScrollY = prevState.scrollY;
22 | const prevScrollX = prevState.scrollX;
23 |
24 | return {
25 | scrollY: window.scrollY,
26 | scrollX: window.scrollX,
27 | directionY: prevScrollY < window.scrollY ? 1 : -1,
28 | directionX: prevScrollX < window.scrollX ? 1 : -1,
29 | };
30 | });
31 | // eslint-disable-next-line react-hooks/exhaustive-deps
32 | }, [
33 | scrollbar?.scrollY,
34 | scrollbar?.scrollX,
35 | scrollbar?.directionY,
36 | scrollbar?.directionX,
37 | ]);
38 |
39 | useIsomorphicLayoutEffect(() => {
40 | /* Add event listener */
41 | window.addEventListener('scroll', updateScrollbar);
42 |
43 | /* Remove event listener on cleanup */
44 | return () => window.removeEventListener('scroll', updateScrollbar);
45 | }, [updateScrollbar]);
46 |
47 | return scrollbar;
48 | }
49 |
--------------------------------------------------------------------------------
/hooks/useWindowSize.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect';
3 |
4 | interface Size {
5 | width: number | undefined;
6 | height: number | undefined;
7 | }
8 |
9 | interface WindowSize {
10 | windowSize: Size;
11 | isMobile: boolean;
12 | isDesktop: boolean;
13 | }
14 |
15 | export default function useWindowSize(): WindowSize {
16 | /* Initialize state with undefined width/height so server and client renders match */
17 | const [windowSize, setWindowSize] = useState({
18 | width: undefined,
19 | height: undefined,
20 | });
21 |
22 | /* Handler to call on window resize */
23 | const handleResize = () => {
24 | setWindowSize({
25 | width: window.innerWidth,
26 | height: window.innerHeight,
27 | });
28 | };
29 |
30 | useIsomorphicLayoutEffect(() => {
31 | /* Add event listener */
32 | window.addEventListener('resize', handleResize);
33 |
34 | /* Call handler right away so state gets updated with initial window size */
35 | handleResize();
36 |
37 | /* Remove event listener on cleanup */
38 | return () => window.removeEventListener('resize', handleResize);
39 | }, []); /* Empty array ensures that effect is only run on mount */
40 |
41 | return {
42 | windowSize,
43 | isMobile:
44 | typeof windowSize?.width === 'number' && windowSize?.width < 1200,
45 | isDesktop:
46 | typeof windowSize?.width === 'number' && windowSize?.width >= 1200,
47 | };
48 | }
49 |
--------------------------------------------------------------------------------
/styles/objects/_container.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Objects / Container
3 | ========================================================================== */
4 |
5 | :root {
6 | --grid-gutter-width: #{$grid-gutter-width};
7 | --breakpoint: 100%;
8 | --breakpoint-max: #{$scr-max};
9 |
10 | @each $breakpoint, $value in $breakpoints {
11 | @if $value > $bp-class-generation {
12 | @include mediaq('>#{$value}') {
13 | --breakpoint: #{$value};
14 | --container: calc(
15 | var(--breakpoint) - (var(--grid-gutter-width) / 2)
16 | );
17 | }
18 |
19 | @if $value <= 1200 {
20 | @include mediaq('>#{$value}') {
21 | --breakpoint-small: #{$value};
22 | --container-small: calc(
23 | var(--breakpoint-small) - (var(--grid-gutter-width) / 2)
24 | );
25 | }
26 | }
27 | }
28 | }
29 |
30 | --half-container: var(--grid-gutter-width);
31 |
32 | @include mediaq('>XS') {
33 | --half-container: calc(
34 | ((100% - var(--container)) / 2) + (var(--grid-gutter-width))
35 | );
36 | }
37 | }
38 |
39 | /* Container
40 | ========================================================================== */
41 |
42 | .o-container {
43 | @include make-container();
44 | max-width: var(--container);
45 | }
46 |
47 | .o-container--small {
48 | @include make-container();
49 | max-width: var(--container-small);
50 | }
51 |
--------------------------------------------------------------------------------
/hooks/useElementSize.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | import { useState, useCallback } from 'react';
3 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect';
4 |
5 | interface Size {
6 | width: number;
7 | height: number;
8 | }
9 |
10 | export default function useElementSize<
11 | T extends HTMLElement = HTMLDivElement,
12 | >(): [(node: T | null) => void, Size] {
13 | /**
14 | * Mutable values like 'ref.current' aren't valid dependencies
15 | * because mutating them doesn't re-render the component.
16 | * Instead, we use a state as a ref to be reactive.
17 | */
18 | const [ref, setRef] = useState(null);
19 | const [size, setSize] = useState({
20 | width: 0,
21 | height: 0,
22 | });
23 |
24 | /* Prevent too many rendering using useCallback */
25 | const handleSize = useCallback(() => {
26 | setSize({
27 | width: ref?.getBoundingClientRect().width || 0,
28 | height: ref?.getBoundingClientRect().height || 0,
29 | });
30 | }, [
31 | ref?.getBoundingClientRect().height,
32 | ref?.getBoundingClientRect().width,
33 | ]);
34 |
35 | useIsomorphicLayoutEffect(() => {
36 | /* Add event listener */
37 | window.addEventListener('resize', handleSize);
38 |
39 | /* Call handler right away so state gets updated with initial element size */
40 | handleSize();
41 |
42 | /* Remove event listener on cleanup */
43 | return () => window.removeEventListener('resize', handleSize);
44 | }, [handleSize]);
45 |
46 | return [setRef, size];
47 | }
48 |
--------------------------------------------------------------------------------
/styles/style.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Style
3 | ========================================================================== */
4 |
5 | /* Settings
6 | ========================================================================== */
7 |
8 | @import 'settings/config.colors';
9 | @import 'settings/config.typography';
10 | @import 'settings/config.eases';
11 | @import 'settings/config';
12 |
13 | /* Tools
14 | ========================================================================== */
15 |
16 | @import 'tools/functions';
17 |
18 | /* Mixins
19 | ========================================================================== */
20 |
21 | @import 'tools/mixins/button';
22 | @import 'tools/mixins/container';
23 | @import 'tools/mixins/grid';
24 | @import 'tools/mixins/form';
25 | @import 'tools/mixins/typography';
26 |
27 | /* Objects
28 | ========================================================================== */
29 |
30 | @import 'objects/mediaq';
31 | @import 'objects/mediaq.export';
32 | @import 'objects/container';
33 | @import 'objects/grid';
34 |
35 | /* Generic
36 | ========================================================================== */
37 |
38 | @import 'generic/normalize';
39 |
40 | /* Utilities
41 | ========================================================================== */
42 |
43 | @import 'utilities/color';
44 | @import 'utilities/spacing';
45 | @import 'utilities/align';
46 | @import 'utilities/helpers';
47 |
48 | /* Base
49 | ========================================================================== */
50 |
51 | @import 'base/document';
52 | @import 'base/form';
53 | @import 'global';
54 |
--------------------------------------------------------------------------------
/components/form/FormInput.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from '@/types/form/elements';
2 | import styles from '../../styles/modules/FormInput.module.scss';
3 | import classNames from 'classnames';
4 |
5 | export default function FormInput({
6 | htmlFor,
7 | label,
8 | type = 'text',
9 | id,
10 | placeholder = ' ',
11 | value,
12 | required,
13 | className,
14 | wrapperClassName,
15 | register,
16 | errors,
17 | }: Input) {
18 | return (
19 |
20 |
30 |
38 | {label && htmlFor && (
39 |
43 | )}
44 |
45 |
46 | {errors?.message && (
47 |
48 | )}
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { FooterProps } from '@/types/components/global';
2 | import styles from '@/styles/modules/Footer.module.scss';
3 | import NavItem from './NavItem';
4 | import classNames from 'classnames';
5 |
6 | export default function Footer({ routes }: FooterProps) {
7 | return (
8 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from 'next/document';
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
7 |
12 |
18 |
24 |
25 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/styles/modules/Button.module.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Button
3 | ========================================================================== */
4 |
5 | .c-btn {
6 | @include make-button();
7 |
8 | &--bordered {
9 | @include make-button();
10 |
11 | --btn-bg-color: transparent;
12 | --btn-color: var(--primary);
13 | --btn-hover-bg-color: var(--primary);
14 | --btn-hover-border-color: var(--primary);
15 | }
16 |
17 | &--secondary {
18 | @include make-button();
19 |
20 | --btn-bg-color: var(--secondary);
21 | --btn-border-color: var(--secondary);
22 | --btn-hover-bg-color: var(--secondary-light);
23 | --btn-hover-border-color: var(--secondary-light);
24 |
25 | &--bordered {
26 | @include make-button();
27 |
28 | --btn-bg-color: transparent;
29 | --btn-border-color: var(--secondary);
30 | --btn-color: var(--secondary);
31 | --btn-hover-bg-color: var(--secondary);
32 | --btn-hover-border-color: var(--secondary);
33 | }
34 | }
35 |
36 | &--icon {
37 | @include make-button();
38 |
39 | display: inline-flex;
40 | align-items: center;
41 |
42 | svg {
43 | width: 16px;
44 | height: 16px;
45 | margin-left: 0.8em;
46 | transition: transform 0.35s $ease-in;
47 | backface-visibility: hidden;
48 | }
49 |
50 | &:hover,
51 | &:focus {
52 | svg {
53 | transform: translate3d(5px, 0, 0);
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/styles/objects/_grid.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Objects / Grid
3 | ========================================================================== */
4 |
5 | :root {
6 | --grid-columns: #{$grid-columns};
7 | }
8 |
9 | /* Grid
10 | ========================================================================== */
11 |
12 | .o-row {
13 | @include make-row();
14 |
15 | &--noGutters {
16 | margin-left: 0;
17 | margin-right: 0;
18 |
19 | > .o-col,
20 | > [class*='col-'] {
21 | padding-left: 0;
22 | padding-right: 0;
23 | }
24 | }
25 | }
26 |
27 | @if $activate-grid-classes {
28 | @for $i from 1 through $grid-columns {
29 | .o-col-#{$i} {
30 | @include make-col-ready();
31 | @include make-col($i, $columns: $grid-columns);
32 | }
33 | }
34 |
35 | @each $breakpoint, $value in $breakpoints {
36 | .o-col-#{$breakpoint} {
37 | @include make-col-ready();
38 | }
39 |
40 | @for $i from 1 through $grid-columns {
41 | .o-col-#{$breakpoint}-#{$i} {
42 | @include make-col-ready();
43 | }
44 | }
45 |
46 | @include mediaq('>#{$value}') {
47 | .o-col-#{$breakpoint} {
48 | @include make-col-ready();
49 | flex-basis: 0;
50 | flex-grow: 1;
51 | max-width: 100%;
52 | }
53 |
54 | @for $i from 1 through $grid-columns {
55 | .o-col-#{$breakpoint}-#{$i} {
56 | @include make-col($i, $columns: $grid-columns);
57 | }
58 | }
59 | }
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/styles/utilities/_color.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Utilities / Colors
3 | ========================================================================== */
4 |
5 | /* Block
6 | ========================================================================== */
7 |
8 | @each $name, $value in $theme-colors {
9 | .u-blockColor--#{$name} {
10 | background: var(--#{$name});
11 | }
12 |
13 | .u-blockBodyColor--#{$name} {
14 | --body-bg-color: var(--#{$name});
15 | }
16 | }
17 |
18 | @each $name, $value in $theme-monochromes {
19 | .u-blockColor--#{$name} {
20 | background: var(--#{$name});
21 | }
22 |
23 | .u-blockBodyColor--#{$name} {
24 | --body-bg-color: var(--#{$name});
25 | }
26 | }
27 |
28 | /* Text
29 | ========================================================================== */
30 |
31 | @each $name, $value, $code in $theme-colors {
32 | .u-color--#{$name} {
33 | color: var(--#{$name});
34 | }
35 |
36 | .u-bodyColor--#{$name} {
37 | --body-text-color: var(--#{$name});
38 | }
39 |
40 | .u-headingColor--#{$name} {
41 | --body-heading-color: var(--#{$name});
42 | }
43 |
44 | .u-linkColor--#{$name} {
45 | --body-link-color: var(--#{$name});
46 | }
47 | }
48 |
49 | @each $name, $value in $theme-monochromes {
50 | .u-color--#{$name} {
51 | color: var(--#{$name});
52 | }
53 |
54 | .u-bodyColor--#{$name} {
55 | --body-text-color: var(--#{$name});
56 | }
57 |
58 | .u-headingColor--#{$name} {
59 | --body-heading-color: var(--#{$name});
60 | }
61 |
62 | .u-linkColor--#{$name} {
63 | --body-link-color: var(--#{$name});
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/components/gsap/RotateInOut.tsx:
--------------------------------------------------------------------------------
1 | import { Rotate } from '@/types/animations';
2 | import AnimateInOut from './AnimateInOut';
3 |
4 | export default function RotateInOut({
5 | children,
6 | fade = true,
7 | durationIn = 0.5,
8 | durationOut = 0.25,
9 | delay = 0,
10 | delayOut = 0,
11 | ease = 'power1.out',
12 | easeOut = 'power1.out',
13 | rotate = 0,
14 | rotateTo = 0,
15 | rotateX = 0,
16 | rotateXTo = 0,
17 | rotateY = 0,
18 | rotateYTo = 0,
19 | x = '0px',
20 | y = '0px',
21 | xTo = 0,
22 | yTo = 0,
23 | transformOrigin,
24 | outro,
25 | skipOutro,
26 | watch,
27 | start = 'top bottom',
28 | end = 'bottom top',
29 | scrub = false,
30 | markers,
31 | }: Rotate) {
32 | return (
33 |
61 | {children}
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/styles/settings/_config.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Settings / Config
3 | ========================================================================== */
4 |
5 | /* Breakpoints
6 | ========================================================================== */
7 |
8 | $scr-xxs: 320px;
9 | $scr-xs: 480px;
10 | $scr-sm: 768px;
11 | $scr-md: 991px;
12 | $scr-lg: 1200px;
13 | $scr-xlg: 1450px;
14 | $scr-xxlg: 1650px;
15 | $scr-max: 1920px;
16 |
17 | $breakpoints: (
18 | XXS: $scr-xxs,
19 | XS: $scr-xs,
20 | SM: $scr-sm,
21 | MD: $scr-md,
22 | LG: $scr-lg,
23 | XLG: $scr-xlg,
24 | XXLG: $scr-xxlg,
25 | // MAX: $scr-max
26 | ) !default;
27 |
28 | /* Sizes
29 | ========================================================================== */
30 |
31 | $list-size: ('thinner', 'thin', 'normal', 'large', 'larger') !default;
32 |
33 | /* Text align
34 | ========================================================================== */
35 |
36 | $list-text-align: (center, left, right) !default;
37 |
38 | /* Grid
39 | ========================================================================== */
40 |
41 | $grid-columns: 12;
42 | $grid-gutter-width: 16px;
43 | $bp-class-generation: $scr-xs - 1;
44 |
45 | /* Spacing
46 | ========================================================================== */
47 |
48 | $spacing-responsive-initial: 65px;
49 | $spacing-responsive-increment: 15px;
50 |
51 | $spacing-initial: 17px;
52 | $spacing-ratio: 1.3;
53 |
54 | $margin-initial: 12px;
55 | $margin-ratio: 1.1;
56 |
57 | $padding-initial: 12px;
58 | $padding-ratio: 1.1;
59 |
60 | /* Activate features
61 | ========================================================================== */
62 |
63 | $activate-grid-classes: false;
64 | $activate-align-classes: true;
65 |
--------------------------------------------------------------------------------
/components/TransitionLayout.tsx:
--------------------------------------------------------------------------------
1 | import { TransitionLayout } from '@/types/components/global';
2 | import useTransitionContext from '@/context/transitionContext';
3 | import { useState } from 'react';
4 | import { useRouter } from 'next/router';
5 | import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
6 | import ScrollTrigger from 'gsap/dist/ScrollTrigger';
7 | import useNavigationContext from '@/context/navigationContext';
8 |
9 | export default function TransitionLayout({ children }: TransitionLayout) {
10 | const router = useRouter();
11 | const [displayChildren, setDisplayChildren] = useState(children);
12 | const { timeline, resetTimeline } = useTransitionContext();
13 | const { setCurrentRoute, currentRoute } = useNavigationContext();
14 |
15 | useIsomorphicLayoutEffect(() => {
16 | if (currentRoute !== router.asPath) {
17 | if (timeline?.duration() === 0) {
18 | /* There are no outro animations, so immediately transition */
19 | setDisplayChildren(children);
20 | setCurrentRoute(router.asPath);
21 | window.scrollTo(0, 0);
22 | ScrollTrigger.refresh(true);
23 | return;
24 | }
25 |
26 | timeline?.play().then(() => {
27 | /* Outro complete so reset to an empty paused timeline */
28 | resetTimeline();
29 | setDisplayChildren(children);
30 | setCurrentRoute(router.asPath);
31 | window.scrollTo(0, 0);
32 | ScrollTrigger.refresh(true);
33 | });
34 | } else {
35 | ScrollTrigger.refresh(true);
36 | }
37 | }, [router.asPath]);
38 |
39 | return {displayChildren}
;
40 | }
41 |
--------------------------------------------------------------------------------
/styles/settings/_config.eases.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Settings / Config / Eases
3 | ========================================================================== */
4 |
5 | $stand-curve: cubic-bezier(0.4, 0, 0.2, 1);
6 | $decel-curve: cubic-bezier(0, 0, 0.2, 1);
7 | $accel-curve: cubic-bezier(0.4, 0, 1, 1);
8 | $sharp-curve: cubic-bezier(0.4, 0, 0.6, 1);
9 | $ease-in: cubic-bezier(0.43, 0.045, 0.1, 0.95);
10 |
11 | $ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
12 | $ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
13 | $ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
14 | $ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
15 | $ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
16 | $ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
17 | $ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
18 | $ease-in-back: cubic-bezier(0.6, -0.28, 0.735, 0.045);
19 |
20 | $ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
21 | $ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
22 | $ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
23 | $ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
24 | $ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
25 | $ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
26 | $ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
27 | $ease-out-back: cubic-bezier(0.175, 0.885, 0.32, 1.275);
28 |
29 | $ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
30 | $ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
31 | $ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
32 | $ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
33 | $ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
34 | $ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
35 | $ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
36 | $ease-in-out-back: cubic-bezier(0.68, -0.55, 0.265, 1.55);
37 |
--------------------------------------------------------------------------------
/styles/objects/_mediaq.export.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Objects / Mediaq export
3 | ========================================================================== */
4 |
5 | /**
6 | * Generates a JSON string with each breakpoint's value and information about
7 | * which of the breakpoints are currently active (i.e. viewport width >= breakpoint)
8 | * Ex.: 900px width returns '{"phone":{"value": "320px", "active": true}, "tablet":{"value": "768px", "active": true}, "desktop":{"value": "1024px", "active": false}}'
9 | *
10 | * @param {String} $target-name Name of breakpoint to evaluate
11 | * @return {String} Resulting JSON string
12 | */
13 | @function mq-breakpoints-to-json($target-name) {
14 | $breakpoints-json: ();
15 | $target-value: map-get($breakpoints, $target-name);
16 |
17 | @each $name, $value in $breakpoints {
18 | $breakpoint: '"#{$name}":{"value": "#{$value}", "active": #{$target-value >= $value}}';
19 | $breakpoints-json: append($breakpoints-json, $breakpoint, 'comma');
20 | }
21 |
22 | @return '{#{$breakpoints-json}}';
23 | }
24 |
25 | /**
26 | * Generates the media queries necessary to export breakpoints
27 | *
28 | * @param {String} $element Element to append JSON data to
29 | */
30 | @mixin mq-export($element) {
31 | @each $name, $value in $breakpoints {
32 | @include mediaq('>=#{$name}') {
33 | #{$element} {
34 | content: mq-breakpoints-to-json($name);
35 | display: block;
36 | height: 0;
37 | overflow: hidden;
38 | width: 0;
39 | }
40 | }
41 | }
42 | }
43 |
44 | @include mq-export(
45 | if(
46 | variable-exists('im-export-element'),
47 | $im-export-element + '::after',
48 | 'body::after'
49 | )
50 | );
51 |
--------------------------------------------------------------------------------
/components/Button.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonProps } from '@/types/components/button';
2 | import styles from '@/styles/modules/Button.module.scss';
3 | import Link from 'next/link';
4 | import Circle from './icons/Circle';
5 |
6 | export default function Button({
7 | label,
8 | href,
9 | isExternal,
10 | externalHref,
11 | anchor,
12 | type = 'button',
13 | onClick,
14 | disabled,
15 | className = 'c-btn',
16 | wrapperClassName,
17 | }: ButtonProps) {
18 | if (label && href) {
19 | return (
20 |
21 |
26 | {label}
27 |
28 |
29 | );
30 | }
31 |
32 | if (label && ((isExternal && externalHref) || anchor)) {
33 | return (
34 |
44 | );
45 | }
46 |
47 | return (
48 |
49 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/styles/modules/AccordionItem.module.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Accordion Item
3 | ========================================================================== */
4 |
5 | .c-accordions {
6 | &__item {
7 | padding: 0;
8 | margin: 0;
9 | border: 1px solid var(--gray-600);
10 | overflow: hidden;
11 |
12 | &:first-of-type {
13 | border-top-left-radius: 6px;
14 | border-top-right-radius: 6px;
15 | }
16 |
17 | &:last-of-type {
18 | border-bottom-left-radius: 6px;
19 | border-bottom-right-radius: 6px;
20 | }
21 |
22 | > h1, h2, h3, h4, h5, h6 {
23 | margin: 0;
24 | }
25 |
26 | &__button {
27 | display: flex;
28 | align-items: center;
29 | justify-content: space-between;
30 | width: 100%;
31 | padding: 16px 20px;
32 | color: inherit;
33 | transition: all .35s $ease-in;
34 |
35 | svg {
36 | width: 30px;
37 | transition: transform .35s $ease-in;
38 | margin-left: 15px;
39 | }
40 |
41 | /* states */
42 | &.is-expanded {
43 | background: var(--btn-bg-color);
44 | color: var(--btn-color);
45 |
46 | svg {
47 | transform: rotate(180deg);
48 | }
49 | }
50 | }
51 |
52 | &__container {
53 | height: 0;
54 | opacity: 0;
55 | overflow: hidden;
56 |
57 | &--content {
58 | padding: 16px 20px;
59 |
60 | * {
61 | &:first-child {
62 | margin: 0;
63 | }
64 | }
65 | }
66 | }
67 | }
68 | }
--------------------------------------------------------------------------------
/styles/modules/DemoModal.module.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Demo Modal
3 | ========================================================================== */
4 |
5 | .c-demoModal {
6 | --body-text-color: var(--black);
7 | --modal-padding: 64px;
8 | --close-size: 40px;
9 |
10 | position: relative;
11 | background: var(--white);
12 | max-width: 450px;
13 | border-radius: 16px;
14 | text-align: center;
15 | box-shadow: 0 20px 25px -5px rgba(var(--box-shadow-color), 0.1), 0 8px 10px -6px rgba(var(--box-shadow-color), 0.1);
16 | border: 1px solid rgb($gray-200, 0.6);
17 | opacity: 0;
18 | transform: scaleY(0) translateX(-100%);
19 |
20 | &__close {
21 | position: absolute;
22 | top: calc(var(--modal-padding) / 2 - var(--close-size) / 2);
23 | right: calc(var(--modal-padding) / 2 - var(--close-size) / 2);
24 | z-index: 1;
25 | width: var(--close-size);
26 | height: var(--close-size);
27 | line-height: 0;
28 | opacity: 0;
29 | transform: scale(0.01);
30 |
31 | &:before, &:after {
32 | content: '';
33 | position: absolute;
34 | top: 50%;
35 | left: 50%;
36 | width: 28px;
37 | height: 3px;
38 | background: var(--black);
39 | transition: background .35s $ease-in;
40 | }
41 |
42 | &:before {
43 | transform: translate3d(-50%, -50%, 0) rotate(-45deg);
44 | }
45 |
46 | &:after {
47 | transform: translate3d(-50%, -50%, 0) rotate(45deg);
48 | }
49 |
50 | &:hover {
51 | &:before, &:after {
52 | background: var(--body-heading-color);
53 | }
54 | }
55 | }
56 |
57 | &__inner {
58 | padding: var(--modal-padding);
59 | opacity: 0;
60 | }
61 | }
--------------------------------------------------------------------------------
/hooks/useLockedScroll.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect';
3 |
4 | type UseLockedScrollOutput = [boolean, (locked: boolean) => void];
5 |
6 | export default function useLockedScroll(
7 | initialLocked = false,
8 | ): UseLockedScrollOutput {
9 | const [locked, setLocked] = useState(initialLocked);
10 |
11 | /* Do the side effect before render */
12 | useIsomorphicLayoutEffect(() => {
13 | if (!locked) {
14 | return;
15 | }
16 |
17 | /* Save initial window offset width & body style */
18 | const originalDocumentWidth = document.documentElement.offsetWidth;
19 | const originalOverflow = document.body.style.overflow;
20 | const originalPaddingRight = document.body.style.paddingRight;
21 | const originalHeight = document.body.style.height;
22 |
23 | /* Lock body scroll */
24 | document.body.style.overflow = 'hidden';
25 | document.body.style.height = `${100}vh`;
26 | document.body.classList.add('has-scroll-lock');
27 |
28 | /* Get the scrollbar width */
29 | const scrollBarWidth = window.innerWidth - originalDocumentWidth;
30 |
31 | /* Avoid width reflow */
32 | if (scrollBarWidth) {
33 | document.body.style.paddingRight = `${scrollBarWidth}px`;
34 | }
35 |
36 | return () => {
37 | document.body.style.overflow = originalOverflow;
38 | document.body.style.height = originalHeight;
39 | document.body.classList.remove('has-scroll-lock');
40 |
41 | if (scrollBarWidth) {
42 | document.body.style.paddingRight = originalPaddingRight;
43 | }
44 | };
45 | }, [locked]);
46 |
47 | /* Update state if initialLocked changes */
48 | useEffect(() => {
49 | if (locked !== initialLocked) {
50 | setLocked(initialLocked);
51 | }
52 | // eslint-disable-next-line react-hooks/exhaustive-deps
53 | }, [initialLocked]);
54 |
55 | return [locked, setLocked];
56 | }
57 |
--------------------------------------------------------------------------------
/styles/modules/Footer.module.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Footer
3 | ========================================================================== */
4 |
5 | .c-footer {
6 | --footer-padding: 22px;
7 |
8 | position: relative;
9 | padding: var(--footer-padding) 0;
10 | border-top: 1px solid var(--gray-400);
11 | background: var(--section-bg-color);
12 |
13 | &__copyright {
14 | text-align: center;
15 | }
16 |
17 | &__list {
18 | ul {
19 | text-align: center;
20 |
21 | li {
22 | &:last-child {
23 | padding: 0;
24 | margin: 0;
25 | }
26 |
27 | span {
28 | display: block;
29 |
30 | a {
31 | display: inline-block;
32 |
33 | &.is-current-page {
34 | color: var(--secondary);
35 | }
36 | }
37 | }
38 | }
39 | }
40 | }
41 |
42 | // **---------------------------------------------------**
43 | // MEDIA QUERIES
44 |
45 | @include mediaq('SM') {
59 | &__row {
60 | display: flex;
61 | flex-direction: row-reverse;
62 | justify-content: space-between;
63 | }
64 |
65 | &__list {
66 | ul {
67 | display: flex;
68 | margin: 0;
69 |
70 | li {
71 | margin: 0;
72 | padding: 0;
73 |
74 | &:not(:last-child) {
75 | margin-right: 15px;
76 | }
77 | }
78 | }
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/components/MetaData.tsx:
--------------------------------------------------------------------------------
1 | import { MetaDataProps } from '@/types/components/global';
2 | import Head from 'next/head';
3 | import useWindowLocation from '@/hooks/useWindowLocation';
4 |
5 | export default function MetaData({ ...customMeta }: MetaDataProps) {
6 | const { currentURL } = useWindowLocation();
7 | const meta: MetaDataProps = {
8 | title: 'Next.js TypeScript starter',
9 | description:
10 | 'A Next.js TypeScript starter that includes a collection of reusable components, hooks, and utilities to build amazing projects with complex animations and page transitions using GSAP.',
11 | image: `${process.env.NEXT_PUBLIC_BASE_URL}/static/og-image.png`,
12 | type: 'website',
13 | ...customMeta,
14 | };
15 |
16 | return (
17 |
18 |
19 |
20 |
24 | {meta.title}
25 |
26 |
27 |
28 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/components/gsap/AnimateInOut.tsx:
--------------------------------------------------------------------------------
1 | import { Animation } from '@/types/animations';
2 | import gsap from 'gsap';
3 | import React, { useRef } from 'react';
4 | import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
5 | import useTransitionContext from '@/context/transitionContext';
6 |
7 | function AnimateInOut({
8 | children,
9 | durationIn,
10 | durationOut,
11 | delay,
12 | delayOut,
13 | easeOut,
14 | from,
15 | to,
16 | outro,
17 | skipOutro,
18 | watch,
19 | start,
20 | end,
21 | scrub,
22 | markers,
23 | }: Animation) {
24 | const { timeline } = useTransitionContext();
25 | const element = useRef(null);
26 |
27 | useIsomorphicLayoutEffect(() => {
28 | const scrollTrigger = watch
29 | ? {
30 | scrollTrigger: {
31 | trigger: element.current,
32 | start,
33 | end,
34 | scrub,
35 | markers: markers,
36 | },
37 | }
38 | : {};
39 |
40 | const ctx = gsap.context(() => {
41 | /* Intro animation */
42 | gsap.to(element.current, {
43 | ...to,
44 | delay,
45 | duration: durationIn,
46 | ...scrollTrigger,
47 | });
48 |
49 | /* Outro animation */
50 | if (!skipOutro) {
51 | const outroProperties = outro ?? from;
52 | timeline?.add(
53 | gsap.to(element.current, {
54 | ease: easeOut,
55 | ...outroProperties,
56 | delay: delayOut,
57 | duration: durationOut,
58 | }),
59 | 0,
60 | );
61 | }
62 | }, element);
63 | return () => ctx.revert();
64 | }, []);
65 |
66 | return (
67 |
68 | {children}
69 |
70 | );
71 | }
72 |
73 | export default React.memo(AnimateInOut);
74 |
--------------------------------------------------------------------------------
/styles/base/_form.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Base / Form
3 | ========================================================================== */
4 |
5 | .c-formElement {
6 | @include default-styles();
7 |
8 | /* Submit Button
9 | ========================================================================== */
10 |
11 | @keyframes spin {
12 | 100% {
13 | rotate: 360deg;
14 | }
15 | }
16 |
17 | @keyframes dash {
18 | 0% {
19 | stroke-dasharray: 1, 150;
20 | stroke-dashoffset: 0;
21 | }
22 | 50% {
23 | stroke-dasharray: 90, 150;
24 | stroke-dashoffset: -35;
25 | }
26 | 100% {
27 | stroke-dasharray: 90, 150;
28 | stroke-dashoffset: -124;
29 | }
30 | }
31 |
32 | &--submit {
33 | [type='submit'] {
34 | display: inline-flex;
35 | align-items: center;
36 | opacity: 0.5;
37 | pointer-events: none;
38 |
39 | svg {
40 | width: 20px;
41 | height: 20px;
42 | margin-right: 10px;
43 | animation: spin 1s linear infinite;
44 |
45 | circle {
46 | fill: none;
47 | stroke: var(--btn-color);
48 | stroke-width: 3px;
49 | stroke-linecap: round;
50 | animation: dash 2.5s ease-in-out infinite;
51 | }
52 | }
53 | }
54 | }
55 | }
56 |
57 | /* Label
58 | ========================================================================== */
59 |
60 | label,
61 | .c-label {
62 | display: block;
63 | margin-bottom: var(--margin-thinner);
64 | }
65 |
66 | /* Toastify
67 | ========================================================================== */
68 |
69 | .c-toastify {
70 | .Toastify__toast {
71 | background: var(--body-bg-color);
72 | color: var(--body-text-color);
73 |
74 | .Toastify__close-button {
75 | color: var(--body-text-color);
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/styles/utilities/_spacing.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Utilities / Spacing
3 | ========================================================================== */
4 |
5 | :root {
6 | --spacing: #{$spacing-initial};
7 | --spacing-ratio: #{$spacing-ratio};
8 | --margin: #{$margin-initial};
9 | --margin-ratio: #{$margin-ratio};
10 | --padding: #{$padding-initial};
11 | --padding-ratio: #{$padding-ratio};
12 | --spacing-responsive: #{$spacing-responsive-initial};
13 | --spacing-responsive-initial: #{$spacing-responsive-initial};
14 | --spacing-responsive-increment: #{$spacing-responsive-increment};
15 |
16 | $i: 1;
17 | @each $size in $list-size {
18 | --spacing-#{$size}: calc(
19 | var(--spacing) * (var(--spacing-ratio) * #{$i})
20 | );
21 | --margin-#{$size}: calc(var(--margin) * (var(--margin-ratio) * #{$i}));
22 | --padding-#{$size}: calc(
23 | var(--padding) * (var(--padding-ratio) * #{$i})
24 | );
25 | $i: $i + 1;
26 | }
27 |
28 | $i: 1;
29 | @each $breakpoint, $value in $breakpoints {
30 | @if $value > 767 {
31 | @include mediaq('>#{$breakpoint}') {
32 | --spacing-responsive: calc(
33 | var(--spacing-responsive-initial) +
34 | (var(--spacing-responsive-increment) * #{$i})
35 | );
36 | }
37 | $i: $i + 1;
38 | }
39 | }
40 | }
41 |
42 | /* Spacing
43 | ========================================================================== */
44 |
45 | .u-spacing--responsive {
46 | padding: var(--spacing-responsive) 0;
47 |
48 | &--top {
49 | padding: var(--spacing-responsive) 0 0;
50 | }
51 |
52 | &--bottom {
53 | padding: 0 0 var(--spacing-responsive);
54 | }
55 | }
56 |
57 | /* Margin
58 | ========================================================================== */
59 |
60 | .u-margin--none {
61 | margin: 0;
62 | }
63 |
64 | /* Padding
65 | ========================================================================== */
66 |
67 | .u-padding--none {
68 | padding: 0;
69 | }
70 |
--------------------------------------------------------------------------------
/components/form/FormSelect.tsx:
--------------------------------------------------------------------------------
1 | import { Select } from '@/types/form/elements';
2 | import styles from '../../styles/modules/FormSelect.module.scss';
3 | import classNames from 'classnames';
4 | import Chevron from '../icons/Chevron';
5 | import { slugify } from '@/utils/string';
6 |
7 | export default function FormSelect({
8 | defaultValue,
9 | htmlFor,
10 | label,
11 | id,
12 | required,
13 | className,
14 | wrapperClassName,
15 | options,
16 | register,
17 | errors,
18 | }: Select) {
19 | return (
20 |
21 |
31 |
32 |
50 | {label && htmlFor && (
51 |
55 | )}
56 |
57 |
58 | {errors?.message && (
59 |
60 | )}
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/types/animations/index.ts:
--------------------------------------------------------------------------------
1 | import { AnimationProperties } from './properties';
2 | import { CSSProperties, ReactNode } from 'react';
3 |
4 | /* Animation */
5 | export type Animation = {
6 | children: ReactNode;
7 | durationIn: number;
8 | durationOut: number;
9 | delay: number;
10 | delayOut: number;
11 | easeOut?: string;
12 | from: CSSProperties;
13 | to: GSAPTweenVars;
14 | outro?: GSAPTweenVars;
15 | skipOutro: boolean | undefined;
16 | watch: boolean | undefined;
17 | start: string;
18 | end: string;
19 | scrub: boolean;
20 | markers: boolean | undefined;
21 | };
22 |
23 | /* Animations */
24 | export type ClipPath = {
25 | children: ReactNode;
26 | fade?: boolean;
27 | clipPath: string;
28 | clipPathTo?: string;
29 | clipPathOut?: string;
30 | } & AnimationProperties;
31 |
32 | export type Fade = {
33 | children: ReactNode;
34 | } & AnimationProperties;
35 |
36 | export type ImplodeExplode = {
37 | children: ReactNode;
38 | target: string;
39 | } & AnimationProperties;
40 |
41 | export type Rotate = {
42 | children: ReactNode;
43 | fade?: boolean;
44 | rotate?: number;
45 | rotateTo?: number;
46 | rotateY?: number;
47 | rotateYTo?: number;
48 | rotateX?: number;
49 | rotateXTo?: number;
50 | x?: string;
51 | y?: string;
52 | xTo?: number;
53 | yTo?: number;
54 | transformOrigin?: string;
55 | } & AnimationProperties;
56 |
57 | export type Rotate3D = {
58 | children: ReactNode;
59 | x?: string;
60 | y?: string;
61 | } & AnimationProperties;
62 |
63 | export type Scale = {
64 | children: ReactNode;
65 | fade?: boolean;
66 | scale?: string;
67 | scaleTo?: string;
68 | x?: string;
69 | y?: string;
70 | xTo?: number;
71 | yTo?: number;
72 | transformOrigin?: string;
73 | } & AnimationProperties;
74 |
75 | export type ShuffleText = {
76 | children: ReactNode;
77 | fade?: boolean;
78 | revealDelayIn?: number;
79 | revealDelayOut?: number;
80 | target: string;
81 | } & AnimationProperties;
82 |
83 | export type Translate = {
84 | children: ReactNode;
85 | fade?: boolean;
86 | x?: string;
87 | y?: string;
88 | xTo?: number;
89 | yTo?: number;
90 | transformOrigin?: string;
91 | } & AnimationProperties;
92 |
--------------------------------------------------------------------------------
/styles/modules/MobileNavigation.module.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Mobile Navigation
3 | ========================================================================== */
4 |
5 | .c-mobileNav {
6 | --navigation-primary-padding: 100px;
7 | --navigation-scroll-padding: 100px;
8 |
9 | position: absolute;
10 | top: 100%;
11 | left: 0;
12 | width: 100%;
13 | height: calc(100vh - var(--navigation-height));
14 | pointer-events: none;
15 |
16 | &__scroll {
17 | position: absolute;
18 | top: 0;
19 | left: 0;
20 | width: 100%;
21 | height: 100%;
22 | overflow: auto;
23 | }
24 |
25 | &__primary {
26 | position: relative;
27 | padding: var(--navigation-primary-padding) 0 0 0;
28 |
29 | &--list {
30 | ul {
31 | margin: 0;
32 | padding: 0;
33 | list-style: none;
34 | font-family: var(--font-primary);
35 |
36 | li {
37 | position: relative;
38 | padding: 15px var(--half-container);
39 | margin: 0;
40 |
41 | span {
42 | display: inline-block;
43 | overflow: hidden;
44 |
45 | a {
46 | display: inline-block;
47 | @include font-size(25px);
48 | font-weight: var(--font-regular);
49 | color: var(--navigation-link-color);
50 | text-transform: uppercase;
51 |
52 | &.is-current-page {
53 | color: var(--navigation-link-current-color);
54 | }
55 | }
56 | }
57 | }
58 | }
59 | }
60 | }
61 |
62 | &.is-open {
63 | pointer-events: all;
64 | }
65 |
66 | &.is-open & {
67 | &__scroll {
68 | padding-bottom: var(--navigation-scroll-padding);
69 | }
70 | }
71 |
72 | // **---------------------------------------------------**
73 | // MEDIA QUERIES
74 |
75 | @include mediaq('>LG') {
76 | display: none;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/styles/settings/_config.colors.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Settings / Config / Colors
3 | ========================================================================== */
4 |
5 | $purple: #291d89 !default;
6 | $green: #42bea6 !default;
7 |
8 | /* Primary Color
9 | ========================================================================== */
10 |
11 | $primary: $purple;
12 | $primary-lighter: lighten($primary, 16%);
13 | $primary-light: lighten($primary, 8%);
14 | $primary-dark: darken($primary, 8%);
15 | $primary-darker: darken($primary, 16%);
16 |
17 | /* Secondary Color
18 | ========================================================================== */
19 |
20 | $secondary: $green;
21 | $secondary-lighter: lighten($secondary, 16%);
22 | $secondary-light: lighten($secondary, 8%);
23 | $secondary-dark: darken($secondary, 8%);
24 | $secondary-darker: darken($secondary, 16%);
25 |
26 | $theme-colors: (
27 | 'primary': $primary,
28 | 'primary-lighter': $primary-lighter,
29 | 'primary-light': $primary-light,
30 | 'primary-dark': $primary-dark,
31 | 'primary-darker': $primary-darker,
32 | 'secondary': $secondary,
33 | 'secondary-lighter': $secondary-lighter,
34 | 'secondary-light': $secondary-light,
35 | 'secondary-dark': $secondary-dark,
36 | 'secondary-darker': $secondary-darker,
37 | ) !default;
38 |
39 | /* Monochrome
40 | ========================================================================== */
41 |
42 | $white: #fff !default;
43 | $gray-100: #f8f9fa !default;
44 | $gray-200: #e9ecef !default;
45 | $gray-300: #dee2e6 !default;
46 | $gray-400: #ced4da !default;
47 | $gray-500: #adb5bd !default;
48 | $gray-600: #6c757d !default;
49 | $gray-700: #495057 !default;
50 | $gray-800: #343a40 !default;
51 | $gray-900: #212529 !default;
52 | $black: #000 !default;
53 |
54 | $theme-monochromes: (
55 | 'white': $white,
56 | 'black': $black,
57 | 'gray-100': $gray-100,
58 | 'gray-200': $gray-200,
59 | 'gray-300': $gray-300,
60 | 'gray-400': $gray-400,
61 | 'gray-500': $gray-500,
62 | 'gray-600': $gray-600,
63 | 'gray-700': $gray-700,
64 | 'gray-800': $gray-800,
65 | 'gray-900': $gray-900,
66 | ) !default;
67 |
68 | /* Alert
69 | ========================================================================== */
70 |
71 | $success: #48c774 !default;
72 | $info: #3298dc !default;
73 | $warning: #ffdd57 !default;
74 | $error: #f14668 !default;
75 |
--------------------------------------------------------------------------------
/components/form/FormFileInput.tsx:
--------------------------------------------------------------------------------
1 | import { FileInput } from '@/types/form/elements';
2 | import styles from '../../styles/modules/FormInput.module.scss';
3 | import { ChangeEvent, useEffect, useState } from 'react';
4 | import classNames from 'classnames';
5 | import FileUpload from '../icons/FileUpload';
6 |
7 | export default function FormFileInput({
8 | htmlFor,
9 | label,
10 | id,
11 | required,
12 | className,
13 | wrapperClassName,
14 | errors,
15 | controller,
16 | }: FileInput) {
17 | const [labelValue, setLabelValue] = useState(label);
18 |
19 | /* Sets input and label value */
20 | const updateOnChange = (e: ChangeEvent) => {
21 | if (e.target.files) {
22 | controller.field.onChange(
23 | (e.target.files.length && e.target.files) || '',
24 | );
25 | setLabelValue(e.target.files[0]?.name ?? label);
26 | }
27 | };
28 |
29 | /* Reset label after successful submit */
30 | useEffect(() => {
31 | if (controller.formState.isSubmitSuccessful) setLabelValue(label);
32 | // eslint-disable-next-line react-hooks/exhaustive-deps
33 | }, [controller.formState.isSubmitSuccessful]);
34 |
35 | return (
36 |
37 |
46 |
55 |
56 | {label && htmlFor && (
57 |
61 | )}
62 |
63 |
64 | {errors?.message && (
65 |
66 | )}
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/schemas/uploadForm.ts:
--------------------------------------------------------------------------------
1 | import { UploadFormData } from '@/types/form';
2 | import { object, string, mixed, addMethod, ObjectSchema } from 'yup';
3 | import formidable from 'formidable';
4 |
5 | const getFormSchema = () => {
6 | /* Override the email method, if email isn't required we need to add excludeEmptyString: true */
7 | addMethod(string, 'email', function validateEmail(message: string) {
8 | return this.matches(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i, {
9 | message,
10 | name: 'email',
11 | });
12 | });
13 |
14 | const schema: ObjectSchema = object({
15 | firstname: string().required('This field is required'),
16 | lastname: string().required('This field is required'),
17 | email: string()
18 | .required('This field is required')
19 | .email('Invalid email address'),
20 | resume: mixed()
21 | .test('required', 'This field is required', (files) =>
22 | files ? true : false,
23 | )
24 | .test(
25 | 'fileType',
26 | 'Unauthorized format, only jpeg, jpg, png, doc, docx and pdf are valid',
27 | (files) =>
28 | !files ||
29 | new RegExp(/[^\s]+(.*?).(jpe?g|png|docx?|pdf)$/i).test(
30 | files[0]?.name || (files.originalFilename as string),
31 | ),
32 | )
33 | .test(
34 | 'fileSize',
35 | 'Max file size 4MB exceeded',
36 | (files) =>
37 | !files || (files[0]?.size || files.size) <= 4 * 1024 * 1024,
38 | ),
39 | coverletter: mixed()
40 | .test(
41 | 'fileType',
42 | 'Unauthorized format, only doc, docx and pdf are valid',
43 | (files) =>
44 | !files ||
45 | new RegExp(/[^\s]+(.*?).(docx?|pdf)$/i).test(
46 | files[0]?.name || (files.originalFilename as string),
47 | ),
48 | )
49 | .test(
50 | 'fileSize',
51 | 'Max file size 4MB exceeded',
52 | (files) =>
53 | !files || (files[0]?.size || files.size) <= 4 * 1024 * 1024,
54 | ),
55 | message: string().required('This field is required'),
56 | });
57 |
58 | return schema;
59 | };
60 |
61 | export const uploadSchema = getFormSchema();
62 |
--------------------------------------------------------------------------------
/components/gsap/ClipPathInOut.tsx:
--------------------------------------------------------------------------------
1 | import { ClipPath } from '@/types/animations';
2 | import gsap from 'gsap';
3 | import { useRef } from 'react';
4 | import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
5 | import useTransitionContext from '@/context/transitionContext';
6 |
7 | export default function ClipPathInOut({
8 | children,
9 | fade = true,
10 | durationIn = 1.25,
11 | durationOut = 0.35,
12 | delay = 0,
13 | delayOut = 0,
14 | ease,
15 | easeOut,
16 | clipPath,
17 | clipPathTo = 'inset(0% 0% 0% 0%)',
18 | clipPathOut,
19 | skipOutro,
20 | watch,
21 | start = 'top bottom',
22 | end = 'bottom top',
23 | scrub = false,
24 | markers,
25 | }: ClipPath) {
26 | const { timeline } = useTransitionContext();
27 | const element = useRef(null);
28 |
29 | useIsomorphicLayoutEffect(() => {
30 | const scrollTrigger = watch
31 | ? {
32 | scrollTrigger: {
33 | trigger: element.current,
34 | start,
35 | end,
36 | scrub,
37 | markers: markers,
38 | },
39 | }
40 | : {};
41 |
42 | const ctx = gsap.context(() => {
43 | /* Intro animation */
44 | gsap.fromTo(
45 | element.current,
46 | {
47 | opacity: fade ? 0 : 1,
48 | clipPath,
49 | ease: ease,
50 | },
51 | {
52 | opacity: 1,
53 | clipPath: clipPathTo,
54 | ease: ease,
55 | delay,
56 | duration: durationIn,
57 | ...scrollTrigger,
58 | },
59 | );
60 |
61 | /* Outro animation */
62 | if (!skipOutro) {
63 | timeline?.add(
64 | gsap.to(element.current, {
65 | clipPath: clipPathOut ?? clipPath,
66 | ease: easeOut,
67 | delay: delayOut,
68 | duration: durationOut,
69 | }),
70 | 0,
71 | );
72 | }
73 |
74 | gsap.to(element.current, {
75 | opacity: 1,
76 | });
77 | }, element);
78 | return () => ctx.revert();
79 | }, []);
80 |
81 | return (
82 |
83 | {children}
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/types/form/elements.ts:
--------------------------------------------------------------------------------
1 | import {
2 | InputHTMLAttributes,
3 | SelectHTMLAttributes,
4 | TextareaHTMLAttributes,
5 | } from 'react';
6 | import {
7 | FieldError,
8 | Merge,
9 | UseControllerReturn,
10 | UseFormRegisterReturn,
11 | } from 'react-hook-form';
12 | import { UploadFormData } from '.';
13 |
14 | /* Elements */
15 | export interface Input extends InputHTMLAttributes {
16 | htmlFor: string;
17 | label: string;
18 | id: string;
19 | className: string;
20 | wrapperClassName?: string;
21 | register: UseFormRegisterReturn;
22 | errors: FieldError | undefined;
23 | }
24 |
25 | export interface FileInput extends InputHTMLAttributes {
26 | htmlFor: string;
27 | label: string;
28 | id: string;
29 | className: string;
30 | wrapperClassName?: string;
31 | errors: FieldError | undefined;
32 | controller:
33 | | UseControllerReturn
34 | | UseControllerReturn;
35 | }
36 |
37 | export interface Checkbox extends InputHTMLAttributes {
38 | htmlFor: string;
39 | label: string;
40 | id: string;
41 | className: string;
42 | wrapperClassName?: string;
43 | register: UseFormRegisterReturn;
44 | }
45 |
46 | export type CheckboxList = {
47 | title: string;
48 | items: string[];
49 | className?: string;
50 | htmlFor: string;
51 | register: UseFormRegisterReturn;
52 | errors: Merge | undefined;
53 | };
54 |
55 | export interface Radio extends InputHTMLAttributes {
56 | htmlFor: string;
57 | label: string;
58 | id: string;
59 | className: string;
60 | wrapperClassName?: string;
61 | register: UseFormRegisterReturn;
62 | }
63 |
64 | export type RadioList = {
65 | title: string;
66 | items: string[];
67 | className?: string;
68 | htmlFor: string;
69 | register: UseFormRegisterReturn;
70 | errors: FieldError | undefined;
71 | };
72 |
73 | export interface Select extends SelectHTMLAttributes {
74 | defaultValue?: string;
75 | htmlFor: string;
76 | label: string;
77 | id: string;
78 | className: string;
79 | wrapperClassName?: string;
80 | options: string[];
81 | register: UseFormRegisterReturn;
82 | errors: FieldError | undefined;
83 | }
84 |
85 | export interface Textarea extends TextareaHTMLAttributes {
86 | htmlFor: string;
87 | label: string;
88 | id: string;
89 | className: string;
90 | wrapperClassName?: string;
91 | register: UseFormRegisterReturn;
92 | errors: FieldError | undefined;
93 | }
94 |
--------------------------------------------------------------------------------
/pages/api/form.ts:
--------------------------------------------------------------------------------
1 | import { FieldsValidationErrors } from '@/types/form';
2 | import Email from '@/utils/email';
3 | import type { NextApiRequest, NextApiResponse } from 'next';
4 | import { getEmailTemplateFile } from '@/utils/template';
5 | import { ValidationError } from 'yup';
6 | import { formSchema } from '@/schemas/form';
7 | import { validateRecaptcha } from '@/utils/recaptcha';
8 |
9 | /**
10 | * Handler
11 | *
12 | * https://nextjs.org/docs/api-routes/introduction
13 | */
14 | export default async function handler(
15 | req: NextApiRequest,
16 | res: NextApiResponse,
17 | ) {
18 | if (req.method !== 'POST') {
19 | res.setHeader('Allow', 'POST');
20 | return res.status(405).end('Method not allowed');
21 | }
22 |
23 | try {
24 | /* Destructures body */
25 | const { recaptchaToken, labels, data } = req.body;
26 |
27 | /* Validation */
28 | await formSchema.validate({ ...data }, { abortEarly: false });
29 |
30 | /* Recaptcha */
31 | const validReCaptcha = await validateRecaptcha(recaptchaToken, res);
32 |
33 | if (validReCaptcha)
34 | /* Sends email */
35 | try {
36 | const emailTemplate = await getEmailTemplateFile(
37 | '/templates/email.html',
38 | res,
39 | );
40 |
41 | await new Email(
42 | emailTemplate as string,
43 | 'New form',
44 | labels,
45 | data,
46 | [],
47 | ).send();
48 |
49 | return res.status(201).json({
50 | data,
51 | message:
52 | 'Thank you, your message has been sent successfully.',
53 | });
54 | } catch (err) {
55 | return res.status(500).json({
56 | data: null,
57 | message: 'An error occurred while sending the email',
58 | });
59 | }
60 | } catch (err) {
61 | /* Yup validation */
62 | if (err instanceof ValidationError) {
63 | const validationErrors: FieldsValidationErrors = {};
64 |
65 | err.inner.forEach((error) => {
66 | if (error.path && !validationErrors[error.path])
67 | validationErrors[error.path] = error.errors[0];
68 | });
69 |
70 | return res
71 | .status(400)
72 | .json({ data: null, errors: validationErrors });
73 | }
74 | /* Global server error */
75 | return res
76 | .status(500)
77 | .json({ data: null, message: 'Internal Server Error' });
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/utils/email.ts:
--------------------------------------------------------------------------------
1 | import { Attachment, Mail, MailFrom, MailTemplate } from '@/types/form/email';
2 | import { Fields, Labels } from '@/types/form';
3 | import sendGrid from '@sendgrid/mail';
4 |
5 | sendGrid.setApiKey(process.env.SENDGRID_API_KEY);
6 |
7 | /**
8 | * Documentation
9 | *
10 | * https://docs.sendgrid.com/api-reference/how-to-use-the-sendgrid-v3-api/authentication
11 | */
12 | export default class Email implements Mail {
13 | siteName: string;
14 | host: string;
15 | template: string;
16 | labels: Labels;
17 | fields: Fields;
18 | to: string;
19 | from: MailFrom;
20 | subject: string;
21 | attachments?: Attachment[];
22 |
23 | constructor(
24 | template: string,
25 | subject: string,
26 | labels: Labels,
27 | fields: Fields,
28 | attachments: Attachment[],
29 | ) {
30 | this.siteName = process.env.NEXT_PUBLIC_SITE_NAME;
31 | this.host = process.env.NEXT_PUBLIC_BASE_URL;
32 | this.template = template;
33 | this.labels = labels;
34 | this.fields = fields;
35 | this.to = process.env.EMAIL_FROM;
36 | this.from = {
37 | email: process.env.EMAIL_FROM,
38 | name: `${fields?.firstname} ${fields?.lastname}`,
39 | };
40 | this.subject = subject;
41 | this.attachments = attachments;
42 | }
43 |
44 | /**
45 | * Sends the email with sendgrid
46 | */
47 | async send() {
48 | const mailOptions = {
49 | to: this.to,
50 | from: {
51 | ...this.from,
52 | },
53 | replyTo: {
54 | email: this.fields?.email,
55 | name: `${this.fields?.firstname} ${this.fields?.lastname}`,
56 | },
57 | subject: this.subject,
58 | ...this.generateTemplate(),
59 | attachments: this.attachments,
60 | };
61 |
62 | await sendGrid.send(mailOptions);
63 | }
64 |
65 | /**
66 | * Generates email template
67 | * @returns {Object} an object containing the email template
68 | */
69 | generateTemplate(): MailTemplate {
70 | const content = Object.entries(this.fields).reduce(
71 | (str, [key, value]) => {
72 | return (str += `${this.labels?.[key]}: ${value}
`);
73 | },
74 | '',
75 | );
76 |
77 | this.template = this.template
78 | .replaceAll('%SITENAME%', this.siteName)
79 | .replaceAll('%HOST%', this.host)
80 | .replace('%CONTENT%', content)
81 | .replace('%YEAR%', new Date().getFullYear().toString());
82 |
83 | return {
84 | html: this.template,
85 | };
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/components/BasicHeader.tsx:
--------------------------------------------------------------------------------
1 | import { BasicHeaderProps } from '@/types/components/headers';
2 | import styles from '@/styles/modules/BasicHeader.module.scss';
3 | import Button from './Button';
4 | import classNames from 'classnames';
5 | import ImplodeExplodeInOut from './gsap/ImplodeExplodeInOut';
6 | import TranslateInOut from './gsap/TranslateInOut';
7 | import ScaleInOut from './gsap/ScaleInOut';
8 |
9 | export default function BasicHeader({
10 | title,
11 | content,
12 | button,
13 | className,
14 | }: BasicHeaderProps) {
15 | return (
16 | <>
17 | {title && (
18 |
24 |
25 |
31 |
32 | {title}
33 |
34 | {content && (
35 |
46 | )}
47 | {button && (
48 |
54 |
60 |
61 | )}
62 |
63 |
64 |
65 | )}
66 | >
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/styles/modules/FormTextarea.module.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Form Textarea
3 | ========================================================================== */
4 |
5 | .c-formElement {
6 | @include default-styles();
7 | @include input-placeholder(inherit, inherit, var(--gray-500));
8 | @include floating-label();
9 |
10 | &--boxed,
11 | &--bordered,
12 | &--line {
13 | textarea {
14 | background: none;
15 | border: none;
16 | font-size: var(--font-base);
17 | height: auto;
18 | max-width: 100%;
19 | outline: none;
20 | width: 100%;
21 | }
22 | }
23 |
24 | &--boxed,
25 | &--bordered {
26 | textarea {
27 | padding: var(--form-element-padding);
28 | min-height: 180px;
29 | }
30 |
31 | /* Colored line on focus */
32 | textarea:focus ~ .c-formElement--focusLine {
33 | border-color: var(--form-element-color);
34 | }
35 |
36 | /* Floating label */
37 | textarea:focus ~ label,
38 | :not(:placeholder-shown) ~ label {
39 | @include font-size(10px);
40 | top: calc((var(--form-element-padding) + 5px) * -1);
41 | opacity: 0.8;
42 | }
43 | }
44 |
45 | &--boxed {
46 | background: var(--gray-200);
47 | }
48 |
49 | &--bordered {
50 | @include bordered-styles();
51 | }
52 |
53 | &--line {
54 | background: transparent;
55 |
56 | hr {
57 | position: absolute;
58 | bottom: 0;
59 | left: 0;
60 | width: 100%;
61 | margin: 0;
62 | height: 1px;
63 | background: var(--gray-700);
64 | }
65 |
66 | .c-formElement--focusLine {
67 | top: calc(100% - 1px);
68 | right: auto;
69 | bottom: auto;
70 | left: 0;
71 | border: none;
72 | width: 0%;
73 | height: 1px;
74 | background: var(--form-element-color);
75 | transition: all 0.35s $ease-in;
76 | }
77 |
78 | &.c-floatingLabel {
79 | label {
80 | left: 0;
81 | }
82 | }
83 |
84 | textarea {
85 | padding: var(--form-element-padding) 0 0 0;
86 | min-height: 51px;
87 | }
88 |
89 | /* Colored line on focus */
90 | textarea:focus ~ .c-formElement--focusLine {
91 | width: 100%;
92 | }
93 |
94 | /* Floating label */
95 | textarea:focus ~ label,
96 | :not(:placeholder-shown) ~ label {
97 | @include font-size(10px);
98 | top: calc((var(--form-element-padding) + 5px) * -1);
99 | opacity: 0.8;
100 | }
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/components/gsap/RotateInOut3D.tsx:
--------------------------------------------------------------------------------
1 | import { Rotate3D } from '@/types/animations';
2 | import gsap from 'gsap';
3 | import { useRef } from 'react';
4 | import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
5 | import useTransitionContext from '@/context/transitionContext';
6 | import { randomNumber } from '@/utils/number';
7 |
8 | export default function RotateInOut3D({
9 | children,
10 | durationIn = 0.5,
11 | durationOut = 0.25,
12 | delay = 0,
13 | delayOut = 0,
14 | ease = 'power4.inOut',
15 | easeOut = 'power4.out',
16 | x = '0px',
17 | y = '0px',
18 | skipOutro,
19 | watch = false,
20 | start = 'top bottom',
21 | end = 'bottom top',
22 | scrub = false,
23 | markers,
24 | }: Rotate3D) {
25 | const { timeline } = useTransitionContext();
26 | const element = useRef(null);
27 |
28 | useIsomorphicLayoutEffect(() => {
29 | const scrollTrigger = watch
30 | ? {
31 | scrollTrigger: {
32 | trigger: element.current,
33 | start,
34 | end,
35 | scrub,
36 | markers: markers,
37 | },
38 | }
39 | : {};
40 |
41 | const ctx = gsap.context(() => {
42 | /* Intro animation */
43 | gsap.fromTo(
44 | element.current,
45 | {
46 | x,
47 | y,
48 | rotationX: randomNumber(-80, 80),
49 | rotationY: randomNumber(-40, 40),
50 | rotationZ: randomNumber(-10, 10),
51 | scale: 0.8,
52 | opacity: 0,
53 | },
54 | {
55 | x: 0,
56 | y: 0,
57 | rotationX: 0,
58 | rotationY: 0,
59 | rotationZ: 0,
60 | scale: 1,
61 | opacity: 1,
62 | ease,
63 | delay,
64 | duration: durationIn,
65 | ...scrollTrigger,
66 | },
67 | );
68 |
69 | /* Outro animation */
70 | if (!skipOutro) {
71 | timeline?.add(
72 | gsap.to(element.current, {
73 | x,
74 | y,
75 | rotationX: randomNumber(-80, 80),
76 | rotationY: randomNumber(-40, 40),
77 | rotationZ: randomNumber(-10, 10),
78 | opacity: 0,
79 | ease: easeOut,
80 | delay: delayOut,
81 | duration: durationOut,
82 | }),
83 | 0,
84 | );
85 | }
86 | }, element);
87 | return () => ctx.revert();
88 | }, []);
89 |
90 | return (
91 |
92 | {children}
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/context/navigationContext.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dispatch,
3 | MutableRefObject,
4 | ReactNode,
5 | RefObject,
6 | SetStateAction,
7 | createContext,
8 | useContext,
9 | useEffect,
10 | useRef,
11 | useState,
12 | } from 'react';
13 | import { useRouter } from 'next/router';
14 | import useScrollbar from '@/hooks/useScrollbar';
15 | import useWindowSize from '@/hooks/useWindowSize';
16 | import useLockedScroll from '@/hooks/useLockedScroll';
17 |
18 | interface NavigationContextType {
19 | navigationRef: MutableRefObject;
20 | mobileNavRef: RefObject;
21 | open: boolean;
22 | sticky: boolean;
23 | hidden: boolean;
24 | toggle: () => void;
25 | currentRoute: string;
26 | setCurrentRoute: Dispatch>;
27 | }
28 |
29 | const NavigationContext = createContext({
30 | navigationRef: {
31 | current: null,
32 | },
33 | mobileNavRef: {
34 | current: null,
35 | },
36 | open: false,
37 | sticky: false,
38 | hidden: false,
39 | toggle: () => {},
40 | currentRoute: '',
41 | setCurrentRoute: () => {},
42 | });
43 |
44 | export function NavigationContextProvider({
45 | children,
46 | }: {
47 | children: ReactNode;
48 | }) {
49 | const router = useRouter();
50 | const navigationRef = useRef(null);
51 | const mobileNavRef = useRef(null);
52 | const [open, setOpen] = useState(false);
53 | const [currentRoute, setCurrentRoute] = useState(router.asPath);
54 | const { scrollY, directionY } = useScrollbar();
55 | const { windowSize, isDesktop } = useWindowSize();
56 | const [locked, setLocked] = useLockedScroll(false);
57 |
58 | const toggle = () => {
59 | setOpen(!open);
60 | setLocked(!locked);
61 | };
62 |
63 | /* Closes navigation if viewport is larger than 1200px */
64 | useEffect(() => {
65 | if (isDesktop) {
66 | setOpen(false);
67 | setLocked(false);
68 | }
69 | // eslint-disable-next-line react-hooks/exhaustive-deps
70 | }, [isDesktop]);
71 |
72 | /* Closes navigation on route change */
73 | useEffect(() => {
74 | if (open) {
75 | setOpen(false);
76 | setLocked(false);
77 | }
78 | // eslint-disable-next-line react-hooks/exhaustive-deps
79 | }, [router.asPath]);
80 |
81 | const contextValue: NavigationContextType = {
82 | navigationRef,
83 | mobileNavRef,
84 | open,
85 | sticky: scrollY > 0,
86 | hidden:
87 | directionY > 0 &&
88 | typeof windowSize.height === 'number' &&
89 | scrollY > windowSize.height,
90 | toggle,
91 | currentRoute,
92 | setCurrentRoute,
93 | };
94 |
95 | return (
96 |
97 | {children}
98 |
99 | );
100 | }
101 |
102 | export default function useNavigationContext(): NavigationContextType {
103 | return useContext(NavigationContext);
104 | }
105 |
--------------------------------------------------------------------------------
/styles/global.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Global
3 | ========================================================================== */
4 |
5 | /* Grid section
6 | ========================================================================== */
7 |
8 | .c-gridSection {
9 | &__row {
10 | display: grid;
11 | grid-template-columns: repeat(1, 1fr);
12 | gap: 25px;
13 | margin: 45px 0 0;
14 | }
15 |
16 | &__item {
17 | --body-text-color: var(--black);
18 |
19 | display: flex;
20 | flex-direction: column;
21 | justify-content: center;
22 | padding: 30px;
23 | border-radius: 16px;
24 | text-align: center;
25 | background: var(--white);
26 | height: 100%;
27 | box-shadow: 0 20px 25px -5px rgba(var(--box-shadow-color), 0.1),
28 | 0 8px 10px -6px rgba(var(--box-shadow-color), 0.1);
29 | border: 1px solid rgb($gray-200, 0.6);
30 | min-height: 265px;
31 | }
32 |
33 | &__rotate {
34 | display: flex;
35 | justify-content: space-around;
36 | align-items: center;
37 | padding: var(--spacing-responsive) 0;
38 |
39 | &--box {
40 | width: 100px;
41 | height: 100px;
42 | background: var(--body-current-page-color);
43 | border-radius: 16px;
44 | }
45 | }
46 |
47 | // **---------------------------------------------------**
48 | // MEDIA QUERIES
49 |
50 | @include mediaq('>SM') {
51 | &__row {
52 | grid-template-columns: repeat(2, 1fr);
53 | }
54 | }
55 |
56 | @include mediaq('>LG') {
57 | &__row {
58 | grid-template-columns: repeat(3, 1fr);
59 | }
60 | }
61 | }
62 |
63 | /* Flex section
64 | ========================================================================== */
65 |
66 | .c-flexSection {
67 | &__row {
68 | margin: 45px 0 0;
69 | }
70 |
71 | &__item {
72 | &:first-child ~ & {
73 | margin: 25px 0 0 0;
74 | }
75 | }
76 |
77 | // **---------------------------------------------------**
78 | // MEDIA QUERIES
79 |
80 | @include mediaq('>SM') {
81 | &__row {
82 | display: flex;
83 | }
84 |
85 | &__item {
86 | &:first-child ~ & {
87 | margin: 0 0 0 25px;
88 | }
89 | }
90 | }
91 | }
92 |
93 | /* Rotate In Out 3D
94 | ========================================================================== */
95 |
96 | .c-rotateInOut3D {
97 | &__row {
98 | display: grid;
99 | grid-template-columns: repeat(5, 1fr);
100 | width: 100%;
101 | padding: var(--spacing-responsive) 0;
102 | }
103 |
104 | &__item {
105 | width: 100%;
106 | height: 150px;
107 |
108 | &--1 {
109 | background: var(--primary-light);
110 | }
111 |
112 | &--2 {
113 | background: var(--primary-lighter);
114 | }
115 |
116 | &--3 {
117 | background: var(--primary);
118 | }
119 |
120 | &--4 {
121 | background: var(--primary-dark);
122 | }
123 |
124 | &--5 {
125 | background: var(--primary-darker);
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/hooks/useNextCssRemovalPrevention.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | /**
4 | * Temporary fix to avoid flash of unstyled content (FOUC) during route transitions
5 | * Keep an eye on this issue and remove this code when resolved: https://github.com/vercel/next.js/issues/17464
6 | */
7 | export default function useNextCssRemovalPrevention() {
8 | useEffect(() => {
9 | /* Gather all server-side rendered stylesheet entries */
10 | let ssrPageStyleSheetsEntries = Array.from(
11 | document.querySelectorAll('link[rel="stylesheet"][data-n-p]'),
12 | ).map((element) => ({
13 | element,
14 | href: element.getAttribute('href'),
15 | }));
16 |
17 | /* Remove the `data-n-p` attribute to prevent Next.js from removing it early */
18 | ssrPageStyleSheetsEntries.forEach(({ element }) =>
19 | element.removeAttribute('data-n-p'),
20 | );
21 |
22 | const fixedStyleHrefs: (string | null)[] = [];
23 |
24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
25 | const mutationHandler = (mutations: any[]) => {
26 | /* Gather all
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 | );
128 | }
129 |
--------------------------------------------------------------------------------
/styles/modules/FormSelect.module.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Form Select
3 | ========================================================================== */
4 |
5 | .c-formElement {
6 | @include default-styles();
7 | @include floating-label();
8 |
9 | &--select {
10 | &--boxed {
11 | background: var(--gray-200);
12 |
13 | svg {
14 | position: absolute;
15 | top: 50%;
16 | right: var(--form-element-padding);
17 | transform: translate3d(0, -50%, 0);
18 | width: 24px;
19 | fill: var(--body-text-color);
20 | pointer-events: none;
21 | }
22 |
23 | select {
24 | appearance: none;
25 | background: transparent;
26 | border-radius: 0;
27 | border: none;
28 | font-size: var(--font-base);
29 | padding: var(--form-element-padding) calc(var(--form-element-padding) + 2em) var(--form-element-padding) var(--form-element-padding);
30 | outline: none;
31 | width: 100%;
32 | cursor: pointer;
33 |
34 | &::-ms-expand {
35 | display: none;
36 | }
37 | }
38 |
39 | /* Colored line on focus */
40 | select:focus ~ .c-formElement--focusLine {
41 | border-color: var(--form-element-color);
42 | }
43 |
44 | /* Floating label */
45 | select:not([value=""]):valid ~ label {
46 | @include font-size(10px);
47 | top: calc((var(--form-element-padding) + 5px) * -1);
48 | opacity: 0.8;
49 | }
50 | }
51 |
52 | &--bordered {
53 | @include bordered-styles();
54 |
55 | svg {
56 | position: absolute;
57 | top: 50%;
58 | right: var(--form-element-padding);
59 | transform: translate3d(0, -50%, 0);
60 | width: 24px;
61 | fill: var(--body-text-color);
62 | pointer-events: none;
63 | }
64 |
65 | select {
66 | appearance: none;
67 | background: transparent;
68 | border-radius: 0;
69 | border: none;
70 | font-size: var(--font-base);
71 | padding: var(--form-element-padding) calc(var(--form-element-padding) + 2em) var(--form-element-padding) var(--form-element-padding);
72 | outline: none;
73 | width: 100%;
74 | cursor: pointer;
75 |
76 | &::-ms-expand {
77 | display: none;
78 | }
79 | }
80 |
81 | /* Colored line on focus */
82 | select:focus ~ .c-formElement--focusLine {
83 | border-color: var(--form-element-color);
84 | }
85 |
86 | /* Floating label */
87 | select:not([value=""]):valid ~ label {
88 | @include font-size(10px);
89 | top: calc((var(--form-element-padding) + 5px) * -1);
90 | opacity: 0.8;
91 | }
92 | }
93 |
94 | &--line {
95 | svg {
96 | position: absolute;
97 | top: 50%;
98 | right: var(--form-element-padding);
99 | transform: translate3d(0, -50%, 0);
100 | width: 24px;
101 | fill: var(--body-text-color);
102 | pointer-events: none;
103 | }
104 |
105 | select {
106 | appearance: none;
107 | background: transparent;
108 | border-radius: 0;
109 | border: none;
110 | font-size: var(--font-base);
111 | padding: var(--form-element-padding) calc(var(--form-element-padding) + 2em) var(--form-element-padding) 0;
112 | outline: none;
113 | width: 100%;
114 | cursor: pointer;
115 |
116 | &::-ms-expand {
117 | display: none;
118 | }
119 | }
120 |
121 | hr {
122 | position: absolute;
123 | bottom: 0;
124 | left: 0;
125 | width: 100%;
126 | margin: 0;
127 | height: 1px;
128 | background: var(--gray-700);
129 | }
130 |
131 | .c-formElement--focusLine {
132 | top: calc(100% - 1px);
133 | right: auto;
134 | bottom: auto;
135 | left: 0;
136 | border: none;
137 | width: 0%;
138 | height: 1px;
139 | background: var(--form-element-color);
140 | transition: all .35s $ease-in;
141 | }
142 |
143 | /* Colored line on focus */
144 | select:focus ~ .c-formElement--focusLine {
145 | width: 100%;
146 | }
147 |
148 | /* Floating label */
149 | select:not([value=""]):valid ~ label {
150 | @include font-size(10px);
151 | top: calc((var(--form-element-padding) + 5px) * -1);
152 | opacity: 0.8;
153 | }
154 | }
155 | }
156 | }
--------------------------------------------------------------------------------
/styles/tools/mixins/_form.scss:
--------------------------------------------------------------------------------
1 | /* ==========================================================================
2 | Tools / Mixins / Form
3 | ========================================================================== */
4 |
5 | @mixin default-styles() {
6 | --form-element-padding: 15px;
7 | --form-element-margin: 35px;
8 |
9 | position: relative;
10 | margin: 0 0 var(--form-element-margin) 0;
11 |
12 | &--marginNone {
13 | margin: 0;
14 | }
15 |
16 | &--focusLine {
17 | display: inline-block;
18 | position: absolute;
19 | top: -1px;
20 | right: -1px;
21 | bottom: -1px;
22 | left: -1px;
23 | z-index: 100;
24 | pointer-events: none;
25 | border: 1px solid;
26 | border-color: transparent;
27 | transition: border-color 0.35s $ease-in;
28 | }
29 |
30 | /* states */
31 | &.has-error {
32 | margin: 0;
33 |
34 | & + label {
35 | --label-margin: 35px;
36 |
37 | background: var(--error);
38 | color: var(--white);
39 | padding: 3px 5px;
40 | margin-bottom: var(--label-margin);
41 | }
42 | }
43 |
44 | &--bordered {
45 | &.has-error {
46 | & + label {
47 | border: 1px solid var(--error);
48 | }
49 | }
50 | }
51 | }
52 |
53 | @mixin input-placeholder($font-size, $line-height, $color) {
54 | ::-webkit-input-placeholder {
55 | color: $color;
56 | font-size: $font-size;
57 | line-height: $line-height;
58 | }
59 |
60 | :-moz-placeholder {
61 | color: $color;
62 | font-size: $font-size;
63 | line-height: $line-height;
64 | }
65 |
66 | ::-moz-placeholder {
67 | color: $color;
68 | font-size: $font-size;
69 | line-height: $line-height;
70 | }
71 |
72 | :-ms-input-placeholder {
73 | color: $color;
74 | font-size: $font-size;
75 | line-height: $line-height;
76 | }
77 |
78 | ::-ms-input-placeholder {
79 | color: $color;
80 | font-size: $font-size;
81 | line-height: $line-height;
82 | }
83 |
84 | :placeholder-shown {
85 | color: $color;
86 | font-size: $font-size;
87 | line-height: $line-height;
88 | }
89 | }
90 |
91 | @mixin input-reset() {
92 | display: block;
93 | padding: var(--form-element-padding);
94 | outline: none;
95 | width: 100%;
96 | -moz-appearance: none;
97 | -webkit-appearance: none;
98 | appearance: none;
99 | background: none;
100 | border: none;
101 | }
102 |
103 | @mixin visually-hidden() {
104 | position: absolute;
105 | margin: -1px;
106 | padding: 0;
107 | height: 1px;
108 | width: 1px;
109 | overflow: hidden;
110 | clip: rect(1px, 1px, 1px, 1px);
111 | clip-path: inset(50%);
112 | }
113 |
114 | @mixin bordered-styles() {
115 | background: transparent;
116 | border: 1px solid var(--gray-700);
117 | }
118 |
119 | @mixin input-checkbox-reset() {
120 | display: inline-block;
121 |
122 | input {
123 | display: none !important;
124 | }
125 |
126 | label {
127 | position: relative;
128 | padding-left: 1.8em;
129 | margin-bottom: 0;
130 | cursor: pointer;
131 |
132 | &:before {
133 | content: '';
134 | position: absolute;
135 | top: 0.08em;
136 | left: 0;
137 | background: var(--white);
138 | border: 1px solid lighten($primary, 25%);
139 | border-radius: 2px;
140 | height: 1.1em;
141 | width: 1.1em;
142 | }
143 | }
144 |
145 | &:first-child ~ & {
146 | margin-left: 0.8em;
147 | }
148 |
149 | :checked + label {
150 | &::before {
151 | background: var(--form-element-color);
152 | border-color: var(--form-element-color);
153 | }
154 | }
155 | }
156 |
157 | @mixin floating-label() {
158 | &.c-floatingLabel {
159 | position: relative;
160 |
161 | label {
162 | position: absolute;
163 | top: var(--form-element-padding);
164 | left: var(--form-element-padding);
165 | transition: all 0.2s $decel-curve;
166 | pointer-events: none;
167 | margin: 0;
168 | }
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/hooks/useSessionStorage.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Dispatch,
3 | SetStateAction,
4 | useCallback,
5 | useEffect,
6 | useState,
7 | } from 'react';
8 |
9 | declare global {
10 | interface WindowEventMap {
11 | 'session-storage': CustomEvent;
12 | }
13 | }
14 |
15 | type SetValue = Dispatch>;
16 |
17 | const IS_SERVER = typeof window === 'undefined';
18 |
19 | export default function useSessionStorage(
20 | key: string,
21 | initialValue: T,
22 | ): [T, SetValue, () => void] {
23 | /* State to store the value */
24 | const [storedValue, setStoredValue] = useState(initialValue);
25 |
26 | /**
27 | * Get from session storage then
28 | * parse stored json or return initialValue
29 | */
30 | const readValue = useCallback((): T => {
31 | /* Prevents build error "window is undefined" but keeps working */
32 | if (IS_SERVER) {
33 | return initialValue;
34 | }
35 |
36 | try {
37 | const item = window.sessionStorage.getItem(key);
38 | return item ? (parseJSON(item) as T) : initialValue;
39 | } catch (error) {
40 | console.warn(`Error reading sessionStorage key "${key}":`, error);
41 | return initialValue;
42 | }
43 | }, [initialValue, key]);
44 |
45 | /**
46 | * Sets the value in sessionStorage
47 | */
48 | const setValue: SetValue = (value) => {
49 | /* Prevents build error "window is undefined" but keeps working */
50 | if (IS_SERVER) {
51 | console.warn(
52 | `Tried setting sessionStorage key "${key}" even though environment is not a client`,
53 | );
54 | }
55 |
56 | try {
57 | /* Allow value to be a function */
58 | const newValue =
59 | value instanceof Function ? value(storedValue) : value;
60 | /* Save to session storage */
61 | window.sessionStorage.setItem(key, JSON.stringify(newValue));
62 | /* Save state */
63 | setStoredValue(newValue);
64 |
65 | /* We dispatch a custom event so every useSessionStorage hook are notified */
66 | window.dispatchEvent(new Event('session-storage'));
67 | } catch (error) {
68 | console.warn(`Error setting sessionStorage key "${key}":`, error);
69 | }
70 | };
71 |
72 | /**
73 | * Removes the value from sessionStorage
74 | */
75 | const removeValue = () => {
76 | /* Prevent build error "window is undefined" but keeps working */
77 | if (IS_SERVER) {
78 | console.warn(
79 | `Tried removing sessionStorage key "${key}" even though environment is not a client`,
80 | );
81 | }
82 |
83 | const defaultValue =
84 | initialValue instanceof Function ? initialValue() : initialValue;
85 |
86 | /* Remove the key from session storage */
87 | window.sessionStorage.removeItem(key);
88 |
89 | /* Save state with default value */
90 | setStoredValue(defaultValue);
91 |
92 | /* We dispatch a custom event so every similar useSessionStorage hook is notified */
93 | window.dispatchEvent(new Event('session-storage'));
94 | };
95 |
96 | useEffect(() => {
97 | setStoredValue(readValue());
98 | // eslint-disable-next-line react-hooks/exhaustive-deps
99 | }, []);
100 |
101 | const handleStorageChange = useCallback(
102 | (event: StorageEvent | CustomEvent) => {
103 | if (
104 | (event as StorageEvent).key &&
105 | (event as StorageEvent).key !== key
106 | ) {
107 | return;
108 | }
109 | setStoredValue(readValue());
110 | },
111 | [key, readValue],
112 | );
113 |
114 | useEffect(() => {
115 | /* This is a custom event, triggered in writeValueToSessionStorage */
116 | window.addEventListener('session-storage', handleStorageChange);
117 |
118 | /* Remove event listeners on cleanup */
119 | return () => {
120 | window.removeEventListener('session-storage', handleStorageChange);
121 | };
122 |
123 | // eslint-disable-next-line react-hooks/exhaustive-deps
124 | }, []);
125 |
126 | return [storedValue, setValue, removeValue];
127 | }
128 |
129 | /**
130 | * A wrapper for "JSON.parse()" to support "undefined" value
131 | */
132 | function parseJSON(value: string | null): T | undefined {
133 | try {
134 | return value === 'undefined' ? undefined : JSON.parse(value ?? '');
135 | } catch {
136 | console.log('parsing error on', { value });
137 | return undefined;
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/hooks/useLocalStorage.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Dispatch,
3 | SetStateAction,
4 | useCallback,
5 | useEffect,
6 | useState,
7 | } from 'react';
8 |
9 | declare global {
10 | interface WindowEventMap {
11 | 'local-storage': CustomEvent;
12 | }
13 | }
14 |
15 | type SetValue = Dispatch>;
16 |
17 | const IS_SERVER = typeof window === 'undefined';
18 |
19 | export default function useLocalStorage(
20 | key: string,
21 | initialValue: T,
22 | ): [T, SetValue, () => void] {
23 | /* State to store the value */
24 | const [storedValue, setStoredValue] = useState(initialValue);
25 |
26 | /**
27 | * Retrieves from the localStorage the value saved with the key argument
28 | * then parse stored json or return initialValue
29 | */
30 | const readValue = useCallback(() => {
31 | /* Prevents build error "window is undefined" but keeps working */
32 | if (IS_SERVER) {
33 | return initialValue;
34 | }
35 |
36 | try {
37 | const item = window.localStorage.getItem(key);
38 | return item ? (parseJSON(item) as T) : initialValue;
39 | } catch (error) {
40 | console.warn(`Error reading localStorage key "${key}":`, error);
41 | return initialValue;
42 | }
43 | }, [initialValue, key]);
44 |
45 | /**
46 | * Sets the value in localStorage
47 | */
48 | const setValue: SetValue = (value) => {
49 | /* Prevents build error "window is undefined" but keeps working */
50 | if (IS_SERVER) {
51 | console.warn(
52 | `Tried setting localStorage key "${key}" even though environment is not a client`,
53 | );
54 | }
55 |
56 | try {
57 | /* Allow value to be a function */
58 | const newValue =
59 | value instanceof Function ? value(storedValue) : value;
60 | /* Save to local storage */
61 | window.localStorage.setItem(key, JSON.stringify(newValue));
62 | /* Save state */
63 | setStoredValue(newValue);
64 |
65 | /* We dispatch a custom event so every useLocalStorage hook are notified */
66 | window.dispatchEvent(new StorageEvent('local-storage', { key }));
67 | } catch (error) {
68 | console.warn(`Error setting localStorage key "${key}":`, error);
69 | }
70 | };
71 |
72 | /**
73 | * Removes the value from localStorage
74 | */
75 | const removeValue = () => {
76 | /* Prevent build error "window is undefined" but keeps working */
77 | if (IS_SERVER) {
78 | console.warn(
79 | `Tried removing localStorage key "${key}" even though environment is not a client`,
80 | );
81 | }
82 |
83 | const defaultValue =
84 | initialValue instanceof Function ? initialValue() : initialValue;
85 |
86 | /* Remove the key from local storage */
87 | window.localStorage.removeItem(key);
88 |
89 | /* Save state with default value */
90 | setStoredValue(defaultValue);
91 |
92 | /* We dispatch a custom event so every similar useLocalStorage hook is notified */
93 | window.dispatchEvent(new StorageEvent('local-storage', { key }));
94 | };
95 |
96 | useEffect(() => {
97 | setStoredValue(readValue());
98 | // eslint-disable-next-line react-hooks/exhaustive-deps
99 | }, []);
100 |
101 | const handleStorageChange = useCallback(
102 | (event: StorageEvent | CustomEvent) => {
103 | if (
104 | (event as StorageEvent).key &&
105 | (event as StorageEvent).key !== key
106 | ) {
107 | return;
108 | }
109 | setStoredValue(readValue());
110 | },
111 | [key, readValue],
112 | );
113 |
114 | useEffect(() => {
115 | /* This only works for other documents, not the current one */
116 | window.addEventListener('storage', handleStorageChange);
117 |
118 | /* This is a custom event, triggered in writeValueToLocalStorage */
119 | window.addEventListener('local-storage', handleStorageChange);
120 |
121 | /* Remove event listeners on cleanup */
122 | return () => {
123 | window.removeEventListener('storage', handleStorageChange);
124 | window.removeEventListener('local-storage', handleStorageChange);
125 | };
126 |
127 | // eslint-disable-next-line react-hooks/exhaustive-deps
128 | }, []);
129 |
130 | return [storedValue, setValue, removeValue];
131 | }
132 |
133 | /**
134 | * A wrapper for "JSON.parse()" to support "undefined" value
135 | */
136 | function parseJSON(value: string | null): T | undefined {
137 | try {
138 | return value === 'undefined' ? undefined : JSON.parse(value ?? '');
139 | } catch {
140 | console.log('parsing error on', { value });
141 | return undefined;
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/components/accordion/AccordionItem.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AccordionItem,
3 | AccordionItemHeading,
4 | HeadingTag,
5 | } from '@/types/components/accordion';
6 | import styles from '@/styles/modules/AccordionItem.module.scss';
7 | import gsap from 'gsap';
8 | import React, { useId, useRef } from 'react';
9 | import useAccordionItem from '@/context/accordionContext';
10 | import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
11 | import useTransitionContext from '@/context/transitionContext';
12 | import { slugify } from '@/utils/string';
13 | import Chevron from '../icons/Chevron';
14 | import classNames from 'classnames';
15 |
16 | export default function AccordionItem({
17 | children,
18 | header,
19 | headingTag = 'h3',
20 | headingClassName = '',
21 | id,
22 | initialExpanded = false,
23 | durationIn = 0.5,
24 | durationOut = 0.25,
25 | delay = 0,
26 | delayOut = 0,
27 | ease = 'sine.out',
28 | easeOut = 'sine.out',
29 | outro,
30 | skipOutro,
31 | watch,
32 | start = 'top bottom',
33 | end = 'bottom top',
34 | scrub = false,
35 | markers,
36 | }: AccordionItem) {
37 | const element = useRef(null);
38 | const container = useRef(null);
39 | const content = useRef(null);
40 | const { expanded, toggle } = useAccordionItem({
41 | id,
42 | initialExpanded,
43 | container,
44 | content,
45 | });
46 | const buttonId = `${slugify(header)}-${useId()}`;
47 | const panelId = `${slugify(header)}-${useId()}`;
48 | const { timeline } = useTransitionContext();
49 | const from = {
50 | opacity: 0,
51 | transform: `translate(0, 100%)`,
52 | };
53 |
54 | useIsomorphicLayoutEffect(() => {
55 | const scrollTrigger = watch
56 | ? {
57 | scrollTrigger: {
58 | trigger: element.current,
59 | start,
60 | end,
61 | scrub,
62 | markers: markers,
63 | },
64 | }
65 | : {};
66 |
67 | const ctx = gsap.context(() => {
68 | /* Intro animation */
69 | gsap.to(element.current, {
70 | ease,
71 | opacity: 1,
72 | x: 0,
73 | y: 0,
74 | delay,
75 | duration: durationIn,
76 | ...scrollTrigger,
77 | });
78 |
79 | /* Outro animation */
80 | if (!skipOutro) {
81 | const outroProperties = outro ?? from;
82 |
83 | timeline?.add(
84 | gsap.to(element.current, {
85 | ease: easeOut,
86 | ...outroProperties,
87 | delay: delayOut,
88 | duration: durationOut,
89 | }),
90 | 0,
91 | );
92 | }
93 | }, element);
94 | return () => ctx.revert();
95 | }, []);
96 |
97 | return (
98 |
103 |
112 |
118 |
122 | {children}
123 |
124 |
125 |
126 | );
127 | }
128 |
129 | function HeadingTag({ headingLevel = 'h3', children, className }: HeadingTag) {
130 | const Heading = ({ ...props }: React.HTMLAttributes) =>
131 | React.createElement(headingLevel, props, children);
132 |
133 | return {children};
134 | }
135 |
136 | function Heading({
137 | header,
138 | headingTag,
139 | headingClassName,
140 | buttonId,
141 | panelId,
142 | expanded,
143 | toggle,
144 | }: AccordionItemHeading) {
145 | return (
146 |
147 |
160 |
161 | );
162 | }
163 |
--------------------------------------------------------------------------------
/context/accordionContext.tsx:
--------------------------------------------------------------------------------
1 | import { AccordionProps } from '@/types/components/accordion';
2 | import gsap from 'gsap';
3 | import ScrollTrigger from 'gsap/dist/ScrollTrigger';
4 | import {
5 | Dispatch,
6 | MutableRefObject,
7 | RefObject,
8 | SetStateAction,
9 | createContext,
10 | useCallback,
11 | useContext,
12 | useEffect,
13 | useRef,
14 | useState,
15 | } from 'react';
16 |
17 | interface Accordion {
18 | id: number;
19 | expanded: boolean;
20 | container: RefObject;
21 | content: RefObject;
22 | }
23 |
24 | interface AccordionContextType {
25 | items: Map | null;
26 | setItem: (accordion: Accordion) => void;
27 | deleteItem: (id: number) => boolean;
28 | toggle: (accordion: Accordion) => void;
29 | }
30 |
31 | const AccordionContext = createContext({
32 | items: null,
33 | setItem: () => {},
34 | deleteItem: () => false,
35 | toggle: () => {},
36 | });
37 |
38 | const updateItem = (
39 | accordion: Accordion,
40 | latestItems: MutableRefObject