├── .prettierignore ├── public ├── static │ ├── logo.png │ ├── react.png │ ├── og-image.png │ └── favicons │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-256x256.png │ │ ├── browserconfig.xml │ │ ├── site.webmanifest │ │ └── safari-pinned-tab.svg └── fonts │ ├── VictorMono-Regular.eot │ ├── VictorMono-Regular.ttf │ ├── VictorMono-Regular.woff │ └── VictorMono-Regular.woff2 ├── prettierrc.json ├── .env.example ├── components ├── icons │ ├── Circle.tsx │ ├── Cross.tsx │ ├── Chevron.tsx │ ├── FileUpload.tsx │ ├── Logo.tsx │ └── Arrow.tsx ├── accordion │ ├── Accordion.tsx │ └── AccordionItem.tsx ├── Layout.tsx ├── form │ ├── FormRadio.tsx │ ├── FormCheckbox.tsx │ ├── FormRecaptchaNote.tsx │ ├── FormRadioList.tsx │ ├── FormCheckboxList.tsx │ ├── FormTextarea.tsx │ ├── FormInput.tsx │ ├── FormSelect.tsx │ └── FormFileInput.tsx ├── NavItem.tsx ├── gsap │ ├── FadeInOut.tsx │ ├── TranslateInOut.tsx │ ├── ScaleInOut.tsx │ ├── RotateInOut.tsx │ ├── AnimateInOut.tsx │ ├── ClipPathInOut.tsx │ ├── RotateInOut3D.tsx │ ├── ImplodeExplodeInOut.tsx │ └── ShuffleTextInOut.tsx ├── modal │ ├── modal.tsx │ └── DemoModal.tsx ├── Footer.tsx ├── TransitionLayout.tsx ├── Button.tsx ├── MetaData.tsx ├── BasicHeader.tsx └── MobileNavigation.tsx ├── hooks ├── useIsomorphicLayoutEffect.ts ├── useWindowLocation.ts ├── useIsMounted.ts ├── useUnsavedChanges.ts ├── useScrollbar.ts ├── useWindowSize.ts ├── useElementSize.ts ├── useLockedScroll.ts ├── useNextCssRemovalPrevention.ts ├── useSessionStorage.ts └── useLocalStorage.ts ├── types ├── components │ ├── headers.ts │ ├── modal.ts │ ├── button.ts │ ├── accordion.ts │ └── global.ts ├── animations │ ├── properties.ts │ └── index.ts └── form │ ├── index.ts │ ├── email.ts │ └── elements.ts ├── styles ├── modules │ ├── Accordion.module.scss │ ├── FormRadio.module.scss │ ├── BasicHeader.module.scss │ ├── Form.module.scss │ ├── Modal.module.scss │ ├── FormCheckbox.module.scss │ ├── Button.module.scss │ ├── AccordionItem.module.scss │ ├── DemoModal.module.scss │ ├── Footer.module.scss │ ├── MobileNavigation.module.scss │ ├── FormTextarea.module.scss │ └── FormSelect.module.scss ├── tools │ ├── mixins │ │ ├── _typography.scss │ │ ├── _container.scss │ │ ├── _button.scss │ │ ├── _grid.scss │ │ └── _form.scss │ └── _functions.scss ├── utilities │ ├── _align.scss │ ├── _helpers.scss │ ├── _color.scss │ └── _spacing.scss ├── settings │ ├── _config.typography.scss │ ├── _config.scss │ ├── _config.eases.scss │ └── _config.colors.scss ├── objects │ ├── _container.scss │ ├── _grid.scss │ └── _mediaq.export.scss ├── style.scss ├── base │ └── _form.scss └── global.scss ├── .eslintrc.json ├── env.d.ts ├── utils ├── number.ts ├── array.ts ├── template.ts ├── string.ts ├── recaptcha.ts └── email.ts ├── tsconfig.json ├── next.config.js ├── .gitignore ├── LICENSE ├── pages ├── form.tsx ├── 404.tsx ├── upload.tsx ├── _document.tsx ├── api │ ├── form.ts │ └── uploadform.ts └── _app.tsx ├── schemas ├── form.ts └── uploadForm.ts ├── context ├── transitionContext.tsx ├── navigationContext.tsx └── accordionContext.tsx ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | package-lock.json 3 | node_modules 4 | .next -------------------------------------------------------------------------------- /public/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/logo.png -------------------------------------------------------------------------------- /public/static/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/react.png -------------------------------------------------------------------------------- /public/static/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/og-image.png -------------------------------------------------------------------------------- /public/static/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/favicons/favicon.ico -------------------------------------------------------------------------------- /public/fonts/VictorMono-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/fonts/VictorMono-Regular.eot -------------------------------------------------------------------------------- /public/fonts/VictorMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/fonts/VictorMono-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/VictorMono-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/fonts/VictorMono-Regular.woff -------------------------------------------------------------------------------- /public/fonts/VictorMono-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/fonts/VictorMono-Regular.woff2 -------------------------------------------------------------------------------- /public/static/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/static/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/static/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /public/static/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/static/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/static/favicons/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dragonsea0927/nextjs-typescript-starter/HEAD/public/static/favicons/android-chrome-256x256.png -------------------------------------------------------------------------------- /prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "bracketSpacing": true, 4 | "semi": true, 5 | "trailingComma": "all", 6 | "singleQuote": true, 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Global 2 | NEXT_PUBLIC_BASE_URL= 3 | NEXT_PUBLIC_SITE_NAME= 4 | 5 | # Sendgrid 6 | EMAIL_FROM= 7 | SENDGRID_API_KEY= 8 | 9 | # Google reCAPTCHA 10 | NEXT_PUBLIC_RECAPTCHA_SITE_KEY= 11 | RECAPTCHA_SECRET_KEY= -------------------------------------------------------------------------------- /components/icons/Circle.tsx: -------------------------------------------------------------------------------- 1 | export default function Circle() { 2 | return ( 3 | 4 | 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /hooks/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from 'react'; 2 | 3 | const useIsomorphicLayoutEffect = 4 | typeof window !== 'undefined' ? useLayoutEffect : useEffect; 5 | 6 | export default useIsomorphicLayoutEffect; 7 | -------------------------------------------------------------------------------- /types/components/headers.ts: -------------------------------------------------------------------------------- 1 | import { ButtonProps } from './button'; 2 | 3 | /* Basic Header */ 4 | export type BasicHeaderProps = { 5 | title: string; 6 | content?: string; 7 | button?: ButtonProps; 8 | className?: string; 9 | }; 10 | -------------------------------------------------------------------------------- /styles/modules/Accordion.module.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Accordion 3 | ========================================================================== */ 4 | 5 | .c-accordions { 6 | margin: 45px 0 0; 7 | } 8 | -------------------------------------------------------------------------------- /components/icons/Cross.tsx: -------------------------------------------------------------------------------- 1 | export default function Cross() { 2 | return ( 3 | 4 | 5 | 6 | 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /public/static/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #42bea6 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /types/components/modal.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | /* Modal */ 4 | export type Modal = { 5 | children: ReactNode; 6 | showModal: boolean; 7 | setModal: (state: boolean) => void; 8 | }; 9 | 10 | /* Demo Modal */ 11 | export type DemoModal = { 12 | title: string; 13 | content: string; 14 | showDemoModal: boolean; 15 | setModal: (state: boolean) => void; 16 | }; 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@typescript-eslint"], 3 | "extends": [ 4 | "next/core-web-vitals", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" 7 | ], 8 | "rules": { 9 | "@typescript-eslint/no-unused-vars": "error", 10 | "@typescript-eslint/no-explicit-any": "error", 11 | "@typescript-eslint/no-empty-function": "off" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /types/components/button.ts: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes } from 'react'; 2 | 3 | /* Button */ 4 | export interface ButtonProps extends ButtonHTMLAttributes { 5 | label: string; 6 | href?: string | object; 7 | isExternal?: boolean; 8 | externalHref?: string; 9 | anchor?: string; 10 | onClick?: () => void; 11 | className?: string; 12 | wrapperClassName?: string; 13 | } 14 | -------------------------------------------------------------------------------- /styles/modules/FormRadio.module.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Form Radio 3 | ========================================================================== */ 4 | 5 | .c-formElement { 6 | @include default-styles(); 7 | 8 | &--radio { 9 | @include input-checkbox-reset(); 10 | 11 | label { 12 | &::before { 13 | border-radius: 40px; 14 | } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /styles/tools/mixins/_typography.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Tools / Mixins / Typography 3 | ========================================================================== */ 4 | 5 | @use 'sass:math'; 6 | 7 | /** 8 | * Generates a converted font-size into REM 9 | * @param font-size in pixel 10 | */ 11 | @mixin font-size($font-size) { 12 | font-size: rem($font-size); 13 | } 14 | -------------------------------------------------------------------------------- /types/animations/properties.ts: -------------------------------------------------------------------------------- 1 | /* Properties */ 2 | export type AnimationProperties = { 3 | durationIn?: number; 4 | durationOut?: number; 5 | delay?: number; 6 | delayOut?: number; 7 | ease?: string; 8 | easeOut?: string; 9 | outro?: GSAPTweenVars; 10 | skipOutro?: boolean; 11 | watch?: boolean; 12 | start?: string; 13 | end?: string; 14 | scrub?: boolean; 15 | markers?: boolean; 16 | }; 17 | -------------------------------------------------------------------------------- /components/icons/Chevron.tsx: -------------------------------------------------------------------------------- 1 | export default function Chevron() { 2 | return ( 3 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | /* Global */ 5 | NEXT_PUBLIC_BASE_URL: string; 6 | NEXT_PUBLIC_SITE_NAME: string; 7 | 8 | /* Sendgrid */ 9 | EMAIL_FROM: string; 10 | SENDGRID_API_KEY: string; 11 | 12 | /* Google reCAPTCHA */ 13 | NEXT_PUBLIC_RECAPTCHA_SITE_KEY: string; 14 | RECAPTCHA_SECRET_KEY: string; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /styles/utilities/_align.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Utilities / Alignment 3 | ========================================================================== */ 4 | 5 | /* Text 6 | ========================================================================== */ 7 | 8 | @if ($activate-align-classes) { 9 | @each $align in $list-text-align { 10 | .u-text--#{$align} { 11 | text-align: #{$align}; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /components/accordion/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { AccordionProps } from '@/types/components/accordion'; 2 | import { AccordionContextProvider } from '@/context/accordionContext'; 3 | import styles from '@/styles/modules/Accordion.module.scss'; 4 | 5 | export default function Accordion({ children, allowMultiple }: AccordionProps) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /hooks/useWindowLocation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | export default function useWindowLocation() { 5 | const [currentURL, setCurrentURL] = useState(''); 6 | const router = useRouter(); 7 | 8 | useEffect(() => { 9 | const urlObj = new URL(window.location.href); 10 | urlObj.search = ''; 11 | urlObj.hash = ''; 12 | setCurrentURL(urlObj.toString()); 13 | }, [router.asPath]); 14 | 15 | return { currentURL }; 16 | } 17 | -------------------------------------------------------------------------------- /styles/tools/mixins/_container.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Tools / Mixins / Container 3 | ========================================================================== */ 4 | 5 | /* Container 6 | ========================================================================== */ 7 | 8 | @mixin make-container() { 9 | width: 100%; 10 | padding-left: var(--grid-gutter-width); 11 | padding-right: var(--grid-gutter-width); 12 | margin-right: auto; 13 | margin-left: auto; 14 | } 15 | -------------------------------------------------------------------------------- /public/static/favicons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/static/favicons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/static/favicons/android-chrome-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from '@/types/components/global'; 2 | import TransitionLayout from './TransitionLayout'; 3 | import Navigation from './Navigation'; 4 | import Footer from './Footer'; 5 | 6 | export default function Layout({ children, routes }: Layout) { 7 | return ( 8 | <> 9 | 10 | 11 |
12 | {children} 13 |
14 |
15 |
16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /components/form/FormRadio.tsx: -------------------------------------------------------------------------------- 1 | import { Radio } from '@/types/form/elements'; 2 | import styles from '../../styles/modules/FormRadio.module.scss'; 3 | import classNames from 'classnames'; 4 | 5 | export default function FormRadio({ 6 | htmlFor, 7 | label, 8 | id, 9 | value, 10 | className, 11 | register, 12 | }: Radio) { 13 | return ( 14 |
15 | 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /hooks/useIsMounted.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | export default function useIsMounted() { 4 | /* Unmounted by default */ 5 | const isMounted = useRef(false); 6 | 7 | useEffect(() => { 8 | /* Mounted */ 9 | isMounted.current = true; 10 | 11 | return () => { 12 | /* Unmounted */ 13 | isMounted.current = false; 14 | }; 15 | }, []); /* Empty array ensures that effect is only run on mount */ 16 | 17 | /* return function that checks mounted status */ 18 | return useCallback(() => isMounted.current, []); 19 | } 20 | -------------------------------------------------------------------------------- /styles/tools/_functions.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Tools / Functions 3 | ========================================================================== */ 4 | 5 | @use 'sass:math'; 6 | 7 | /** 8 | * Converts pixel value into REM 9 | */ 10 | @function rem($size, $base: $font-base) { 11 | @return math.div($size, $base) * 1rem; 12 | } 13 | 14 | /** 15 | * Returns opaque color 16 | * e.g : opaque(#fff, rgba(0, 0, 0, .5)) => #808080 17 | */ 18 | @function opaque($background, $foreground) { 19 | @return mix(rgba($foreground, 1), $background, opacity($foreground) * 100); 20 | } 21 | -------------------------------------------------------------------------------- /utils/number.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates a random number using a minimum and maximum value 3 | * @param {number} min minimum value 4 | * @param {number} max maximum value 5 | * @returns {number} a random number 6 | */ 7 | export const randomNumber = (min: number, max: number): number => { 8 | return Math.floor(Math.random() * (1 + max - min) + min); 9 | }; 10 | 11 | /** 12 | * Formats number by prepending 0 to single-digit number 13 | * @param {number} val number to format 14 | * @returns {string} 2 digits number prepended by a 0 as string 15 | */ 16 | export const toTwoDigits = (val: number): string => { 17 | return `0${val}`.slice(-2); 18 | }; 19 | -------------------------------------------------------------------------------- /components/icons/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | export default function FileUpload() { 2 | return ( 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /styles/modules/BasicHeader.module.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Basic Header 3 | ========================================================================== */ 4 | 5 | .c-basicHeader { 6 | position: relative; 7 | padding: calc(var(--spacing-responsive) + var(--navigation-height)) 0 8 | var(--spacing-responsive) 0; 9 | 10 | &__row { 11 | max-width: 800px; 12 | margin: 0 auto; 13 | } 14 | 15 | &__btn { 16 | margin-top: 35px; 17 | } 18 | 19 | /* modifiers */ 20 | &--fullHeight { 21 | display: flex; 22 | align-items: center; 23 | min-height: 100vh; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /utils/array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shuffles array in place 3 | * @param {Array} array An array containing the items to shuffle 4 | * @returns {Array} a shuffled array 5 | */ 6 | export const shuffle = (array: T[]): T[] => { 7 | for (let i = array.length - 1; i > 0; i--) { 8 | const j = Math.floor(Math.random() * (i + 1)); 9 | [array[i], array[j]] = [array[j], array[i]]; 10 | } 11 | return array; 12 | }; 13 | 14 | /** 15 | * Selects a random item from an array 16 | * @param {Array} array An array containing the items 17 | * @returns {any} a random item 18 | */ 19 | export const randomItem = (array: T[]): T => { 20 | return array[Math.floor(Math.random() * array.length)]; 21 | }; 22 | -------------------------------------------------------------------------------- /styles/modules/Form.module.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Form 3 | ========================================================================== */ 4 | 5 | .c-form { 6 | &__inner { 7 | max-width: 1000px; 8 | margin: 0 auto; 9 | } 10 | 11 | &__btn { 12 | display: inline-flex; 13 | margin-top: 40px; 14 | } 15 | 16 | // **---------------------------------------------------** 17 | // MEDIA QUERIES 18 | 19 | @include mediaq('>SM') { 20 | &__row { 21 | display: grid; 22 | gap: 0 35px; 23 | grid-template-columns: repeat(2, 1fr); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /types/form/index.ts: -------------------------------------------------------------------------------- 1 | /* Form */ 2 | export type FormData = { 3 | firstname?: string; 4 | lastname?: string; 5 | email?: string; 6 | subject?: string; 7 | choices?: (string | undefined)[]; 8 | question?: string; 9 | message?: string; 10 | }; 11 | 12 | export type UploadFormData = { 13 | firstname?: string; 14 | lastname?: string; 15 | email?: string; 16 | resume?: FileList | string; 17 | coverletter?: FileList | string; 18 | message?: string; 19 | }; 20 | 21 | export type Labels = { 22 | [key: string]: string; 23 | }; 24 | 25 | export type Fields = { 26 | [key: string]: string; 27 | }; 28 | 29 | export type FieldsValidationErrors = { 30 | [key: string]: string; 31 | }; 32 | -------------------------------------------------------------------------------- /styles/modules/Modal.module.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Modal 3 | ========================================================================== */ 4 | 5 | .m-modal { 6 | position: fixed; 7 | top: 0; 8 | left: 0; 9 | width: 100%; 10 | height: 100%; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | z-index: 7000; 15 | background: var(--modal-backdrop-color); 16 | backdrop-filter: saturate(180%) blur(5px); 17 | opacity: 0; 18 | pointer-events: none; 19 | 20 | &__backdrop { 21 | position: absolute; 22 | top: 0; 23 | left: 0; 24 | width: 100%; 25 | height: 100%; 26 | } 27 | } -------------------------------------------------------------------------------- /components/form/FormCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox } from '@/types/form/elements'; 2 | import styles from '../../styles/modules/FormCheckbox.module.scss'; 3 | import classNames from 'classnames'; 4 | import Cross from '../icons/Cross'; 5 | 6 | export default function FormCheckbox({ 7 | htmlFor, 8 | label, 9 | id, 10 | value, 11 | className, 12 | register, 13 | }: Checkbox) { 14 | return ( 15 |
16 | 17 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "env.d.ts"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /types/form/email.ts: -------------------------------------------------------------------------------- 1 | import { Fields, Labels } from '.'; 2 | 3 | /* Email */ 4 | export interface Mail { 5 | siteName: string; 6 | host: string; 7 | template: string; 8 | labels: Labels; 9 | fields: Fields; 10 | to: string; 11 | from: MailFrom; 12 | subject: string; 13 | attachments?: Attachment[]; 14 | send: () => Promise; 15 | generateTemplate: () => MailTemplate; 16 | } 17 | 18 | export type MailFrom = { 19 | name?: string; 20 | email: string; 21 | }; 22 | 23 | export type MailTemplate = { 24 | html: string; 25 | }; 26 | 27 | export type Attachment = { 28 | content: string; 29 | filename: string; 30 | type?: string; 31 | disposition?: string; 32 | contentId?: string; 33 | }; 34 | -------------------------------------------------------------------------------- /components/icons/Logo.tsx: -------------------------------------------------------------------------------- 1 | export default function Logo() { 2 | return ( 3 | 9 | 10 | 14 | 18 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | sassOptions: { 5 | additionalData: ` 6 | @import 7 | 'styles/settings/_config.scss', 8 | 'styles/settings/_config.colors.scss', 9 | 'styles/settings/_config.eases.scss', 10 | 'styles/settings/_config.typography.scss', 11 | 'styles/tools/mixins/_button.scss', 12 | 'styles/tools/mixins/_container.scss', 13 | 'styles/tools/mixins/_grid.scss', 14 | 'styles/tools/mixins/_form.scss', 15 | 'styles/tools/mixins/_typography.scss', 16 | 'styles/tools/_functions.scss', 17 | 'styles/objects/_mediaq.scss'; 18 | `, 19 | }, 20 | }; 21 | 22 | module.exports = nextConfig; 23 | -------------------------------------------------------------------------------- /utils/template.ts: -------------------------------------------------------------------------------- 1 | import { NextApiResponse } from 'next'; 2 | 3 | /** 4 | * Gets email template file 5 | * @param {string} path email template path 6 | * @param {Object} res server response object 7 | * @returns {string|void} html email template or JSON response 8 | */ 9 | export const getEmailTemplateFile = async ( 10 | path: string, 11 | res: NextApiResponse, 12 | ): Promise => { 13 | try { 14 | const response = await fetch( 15 | `${process.env.NEXT_PUBLIC_BASE_URL}${path}`, 16 | ); 17 | if (!response.ok) throw new Error('Email template not found'); 18 | return response.text(); 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | } catch (err: any) { 21 | return res.status(404).json({ message: err.message }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Local env files 4 | .env 5 | .env.* 6 | !.env.example 7 | 8 | # Dependencies 9 | /node_modules 10 | /.pnp 11 | .pnp.js 12 | 13 | # Package manager 14 | package-lock.json 15 | yarn.lock 16 | 17 | # Misc 18 | *.pem 19 | prepros.config 20 | .vscode 21 | 22 | # Mac OS X 23 | .DS_Store 24 | .DS_Store? 25 | ._.* 26 | ._* 27 | 28 | # Windows 29 | Thumbs.db 30 | 31 | # Ignore local editor 32 | .project 33 | .settings 34 | .idea 35 | *.swp 36 | tags 37 | nbproject/* 38 | 39 | # Debug 40 | npm-debug.log* 41 | yarn-debug.log* 42 | yarn-error.log* 43 | .pnpm-debug.log* 44 | 45 | # Testing 46 | /coverage 47 | 48 | # Next.js 49 | /.next/ 50 | /out/ 51 | 52 | # Production 53 | /build 54 | 55 | # Vercel 56 | .vercel 57 | 58 | # Typescript 59 | *.tsbuildinfo 60 | next-env.d.ts -------------------------------------------------------------------------------- /components/icons/Arrow.tsx: -------------------------------------------------------------------------------- 1 | export default function Arrow() { 2 | return ( 3 | 9 | 10 | 15 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /styles/settings/_config.typography.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Settings / Config / Typography 3 | ========================================================================== */ 4 | 5 | /* Settings 6 | ========================================================================== */ 7 | 8 | $font-base: 16px; 9 | 10 | /* Fonts 11 | ========================================================================== */ 12 | 13 | /* 14 | 15 | Fonts files are loaded by @next/font in _app 16 | 17 | @font-face { 18 | font-family: 'Victor Mono'; 19 | src: url("../../fonts/VictorMono-Regular.eot") format("eot"), url("../../fonts/VictorMono-Regular.woff") format("woff"), url("../../fonts/VictorMono-Regular.woff2") format("woff2"), url("../../fonts/VictorMono-Regular.ttf") format("truetype"); 20 | font-style: normal; 21 | font-display: swap; 22 | } 23 | 24 | */ 25 | -------------------------------------------------------------------------------- /components/NavItem.tsx: -------------------------------------------------------------------------------- 1 | import { NavItemProps } from '@/types/components/global'; 2 | import Link from 'next/link'; 3 | import useNavigationContext from '@/context/navigationContext'; 4 | import { ForwardedRef, forwardRef } from 'react'; 5 | import classNames from 'classnames'; 6 | 7 | function NavItem( 8 | { href, title, onClick, className, style }: NavItemProps, 9 | ref: ForwardedRef, 10 | ) { 11 | const { currentRoute } = useNavigationContext(); 12 | const isActive = currentRoute === href; 13 | 14 | return ( 15 | 16 | 25 | {title} 26 | 27 | 28 | ); 29 | } 30 | 31 | export default forwardRef(NavItem); 32 | -------------------------------------------------------------------------------- /types/components/accordion.ts: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, ReactNode } from 'react'; 2 | import { AnimationProperties } from '../animations/properties'; 3 | 4 | /* Accordion props */ 5 | export type AccordionProps = { 6 | children: ReactNode; 7 | allowMultiple?: boolean; 8 | }; 9 | 10 | /* Accordion item */ 11 | export interface HeadingTag extends HTMLAttributes { 12 | headingLevel: 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 13 | } 14 | 15 | export type AccordionItemHeading = { 16 | header: string; 17 | headingTag: 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 18 | headingClassName?: string; 19 | buttonId: string; 20 | panelId: string; 21 | expanded: boolean; 22 | toggle: (expanded: boolean) => void; 23 | }; 24 | 25 | export type AccordionItem = { 26 | children: ReactNode; 27 | header: string; 28 | headingTag?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 29 | headingClassName?: string; 30 | id: number; 31 | initialExpanded?: boolean; 32 | } & AnimationProperties; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Gérard Colombi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pages/form.tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps, InferGetStaticPropsType } from 'next'; 2 | import { MetaDataProps } from '@/types/components/global'; 3 | import BasicHeader from '@/components/BasicHeader'; 4 | import Form from '@/components/form/Form'; 5 | 6 | export default function FormPage({}: InferGetStaticPropsType< 7 | typeof getStaticProps 8 | >) { 9 | return ( 10 | <> 11 | 15 |
16 | 17 | ); 18 | } 19 | 20 | export const getStaticProps: GetStaticProps<{ 21 | metaData: MetaDataProps; 22 | }> = async () => { 23 | const metaData: MetaDataProps = { 24 | title: `Form | ${process.env.NEXT_PUBLIC_SITE_NAME}`, 25 | }; 26 | 27 | return { 28 | props: { 29 | metaData, 30 | }, 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /types/components/global.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, ReactNode } from 'react'; 2 | 3 | /* Meta data */ 4 | export type MetaDataProps = { 5 | title?: string; 6 | description?: string; 7 | image?: string; 8 | type?: string; 9 | }; 10 | 11 | /* Navigation */ 12 | export type TogglerProps = { 13 | open: boolean; 14 | toggle: () => void; 15 | }; 16 | 17 | export type NavItemProps = { 18 | href: string; 19 | title: string; 20 | onClick?: () => void; 21 | className: string; 22 | style?: CSSProperties; 23 | }; 24 | 25 | export type NavigationProps = { 26 | routes: NavigationRoutes; 27 | }; 28 | 29 | export type MobileNavigationProps = { 30 | routes: NavigationRoutes; 31 | }; 32 | 33 | export type NavigationRoutes = NavigationRoute[]; 34 | 35 | export type NavigationRoute = { 36 | href: string; 37 | title: string; 38 | }; 39 | 40 | /* Layout */ 41 | export type Layout = { 42 | children: ReactNode; 43 | routes: NavigationRoutes; 44 | }; 45 | 46 | export type TransitionLayout = { 47 | children: ReactNode; 48 | }; 49 | 50 | /* Footer */ 51 | export type FooterProps = { 52 | routes: NavigationRoutes; 53 | }; 54 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps, InferGetStaticPropsType } from 'next'; 2 | import { MetaDataProps } from '@/types/components/global'; 3 | import BasicHeader from '@/components/BasicHeader'; 4 | 5 | export default function PageNotFound({}: InferGetStaticPropsType< 6 | typeof getStaticProps 7 | >) { 8 | return ( 9 | <> 10 | 20 | 21 | ); 22 | } 23 | 24 | export const getStaticProps: GetStaticProps<{ 25 | metaData: MetaDataProps; 26 | }> = async () => { 27 | const metaData: MetaDataProps = { 28 | title: `Error 404 | ${process.env.NEXT_PUBLIC_SITE_NAME}`, 29 | description: 'You are lost in Space!', 30 | }; 31 | 32 | return { 33 | props: { 34 | metaData, 35 | }, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /components/form/FormRecaptchaNote.tsx: -------------------------------------------------------------------------------- 1 | export default function FormRecaptchaNote() { 2 | return ( 3 | <> 4 | {process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY && ( 5 |
6 |

7 | 8 | This site is protected by reCAPTCHA and the Google{' '} 9 | 13 | Privacy Policy 14 | {' '} 15 | and{' '} 16 | 20 | Terms of Service 21 | {' '} 22 | apply. 23 | 24 |

25 |
26 | )} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /components/gsap/FadeInOut.tsx: -------------------------------------------------------------------------------- 1 | import { Fade } from '@/types/animations'; 2 | import AnimateInOut from './AnimateInOut'; 3 | 4 | export default function FadeInOut({ 5 | children, 6 | durationIn = 1, 7 | durationOut = 0.35, 8 | delay = 0, 9 | delayOut = 0, 10 | ease = 'power4.out', 11 | easeOut = 'power4.out', 12 | outro, 13 | skipOutro, 14 | watch, 15 | start = 'top bottom', 16 | end = 'bottom top', 17 | scrub = false, 18 | markers, 19 | }: Fade) { 20 | return ( 21 | 42 | {children} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /styles/modules/FormCheckbox.module.scss: -------------------------------------------------------------------------------- 1 | /* ========================================================================== 2 | Form Checkbox 3 | ========================================================================== */ 4 | 5 | .c-formElement { 6 | @include default-styles(); 7 | 8 | &--checkbox { 9 | @include input-checkbox-reset(); 10 | } 11 | 12 | &--checkboxSvg { 13 | display: inline-block; 14 | 15 | &:first-child ~ & { 16 | margin-left: 0.8em; 17 | } 18 | 19 | input{ 20 | display: none!important; 21 | } 22 | 23 | input:checked ~ label svg path { 24 | stroke-dashoffset: 0; 25 | } 26 | 27 | label { 28 | position: relative; 29 | display: flex; 30 | align-items: center; 31 | margin-bottom: 0; 32 | cursor: pointer; 33 | line-height: 1; 34 | } 35 | 36 | input ~ label svg { 37 | width: 1.1em; 38 | height: 1.1em; 39 | border: 1px solid var(--gray-700); 40 | margin-right: 10px; 41 | } 42 | 43 | input ~ label svg path { 44 | transition: stroke-dashoffset .3s $ease-in; 45 | fill: none; 46 | stroke: var(--gray-700); 47 | stroke-dasharray: 270; 48 | stroke-dashoffset: 270; 49 | stroke-width: 5px; 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /utils/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates slug from string 3 | * 4 | * The normalize() using NFKD method returns the Unicode Normalization Form of a given string 5 | * Deletes all the accents, which happen to be all in the \u03xx UNICODE block 6 | * Convert the string to lowercase letters 7 | * Remove whitespace from both sides of a string (optional) 8 | * Replace spaces with - 9 | * Remove all non-word chars 10 | * Replace _ with - 11 | * Replace multiple - with single - 12 | * Remove trailing - 13 | * 14 | * @param {string} string 15 | * @returns {string} string as slug 16 | */ 17 | export const slugify = (string: string): string => { 18 | return string 19 | .normalize('NFKD') 20 | .replace(/[\u0300-\u036f]/g, '') 21 | .toLowerCase() 22 | .trim() 23 | .replace(/\s+/g, '-') 24 | .replace(/[^\w\-]+/g, '') 25 | .replace(/\_/g, '-') 26 | .replace(/\-\-+/g, '-') 27 | .replace(/\-$/g, ''); 28 | }; 29 | 30 | /** 31 | * Converts first letter to uppercase 32 | * @param {string} string 33 | * @returns {string} capitalized string 34 | */ 35 | export const capitalizeFirstLetter = (string: string): string => { 36 | return string.charAt(0).toUpperCase() + string.slice(1); 37 | }; 38 | -------------------------------------------------------------------------------- /pages/upload.tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps, InferGetStaticPropsType } from 'next'; 2 | import { MetaDataProps } from '@/types/components/global'; 3 | import BasicHeader from '@/components/BasicHeader'; 4 | import UploadForm from '@/components/form/UploadForm'; 5 | 6 | export default function FileUploadForm({}: InferGetStaticPropsType< 7 | typeof getStaticProps 8 | >) { 9 | return ( 10 | <> 11 | 15 | 16 | 17 | ); 18 | } 19 | 20 | export const getStaticProps: GetStaticProps<{ 21 | metaData: MetaDataProps; 22 | }> = async () => { 23 | const metaData: MetaDataProps = { 24 | title: `File upload form | ${process.env.NEXT_PUBLIC_SITE_NAME}`, 25 | }; 26 | 27 | return { 28 | props: { 29 | metaData, 30 | }, 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /components/modal/modal.tsx: -------------------------------------------------------------------------------- 1 | import { Modal } from '@/types/components/modal'; 2 | import styles from '@/styles/modules/Modal.module.scss'; 3 | import { ForwardedRef, forwardRef, useCallback, useEffect } from 'react'; 4 | 5 | function Modal( 6 | { children, showModal, setModal }: Modal, 7 | ref: ForwardedRef, 8 | ) { 9 | const onKeyDown = useCallback( 10 | (e: KeyboardEvent) => { 11 | if (e.key === 'Escape' && showModal) { 12 | setModal(false); 13 | } 14 | }, 15 | [showModal, setModal], 16 | ); 17 | 18 | useEffect(() => { 19 | document.addEventListener('keydown', onKeyDown); 20 | return () => document.removeEventListener('keydown', onKeyDown); 21 | }, [onKeyDown]); 22 | 23 | return ( 24 | <> 25 | {showModal && ( 26 |
27 |
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 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 22 | 23 | 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 |