├── .nvmrc ├── .stylelintignore ├── src ├── assets │ ├── images │ │ ├── .gitkeep │ │ ├── test.png │ │ ├── x-logo.png │ │ ├── github-icon-64.png │ │ ├── npm-icon-64b.png │ │ ├── github-icon-64b.png │ │ └── experience-logo-500.png │ ├── fonts │ │ └── .gitkeep │ └── rive │ │ └── x-intro.riv ├── components │ ├── BaseImage │ │ ├── BaseImage.module.scss │ │ ├── index.ts │ │ ├── BaseImage.stories.tsx │ │ └── BaseImage.controller.tsx │ ├── Footer │ │ ├── index.ts │ │ ├── Footer.module.scss │ │ ├── Footer.stories.tsx │ │ ├── Footer.controller.tsx │ │ └── Footer.view.tsx │ ├── AppAdmin │ │ ├── index.ts │ │ ├── AppAdmin.stories.tsx │ │ ├── AppAdmin.controller.tsx │ │ └── AppAdmin.module.scss │ ├── PageHome │ │ ├── index.ts │ │ ├── PageHome.stories.tsx │ │ ├── PageHome.controller.tsx │ │ └── PageHome.module.scss │ ├── PageAbout │ │ ├── index.ts │ │ ├── PageAbout.stories.tsx │ │ ├── PageAbout.controller.tsx │ │ ├── PageAbout.module.scss │ │ └── PageAbout.view.tsx │ ├── BaseButton │ │ ├── index.ts │ │ ├── BaseButton.stories.tsx │ │ └── BaseButton.controller.tsx │ ├── ScreenIntro │ │ ├── index.ts │ │ ├── ScreenIntro.module.scss │ │ ├── ScreenIntro.stories.tsx │ │ ├── ScreenIntro.controller.tsx │ │ └── ScreenIntro.view.tsx │ ├── CookieBanner │ │ ├── index.ts │ │ ├── CookieBanner.stories.tsx │ │ └── CookieBanner.controller.tsx │ ├── PageNotFound │ │ ├── index.ts │ │ ├── PageNotFound.module.scss │ │ ├── PageNotFound.stories.tsx │ │ ├── PageNotFound.controller.tsx │ │ └── PageNotFound.view.tsx │ ├── ScreenRotate │ │ ├── index.ts │ │ ├── ScreenRotate.module.scss │ │ ├── ScreenRotate.stories.tsx │ │ ├── ScreenRotate.controller.tsx │ │ └── ScreenRotate.view.tsx │ ├── ScreenNoScript │ │ ├── index.ts │ │ ├── ScreenNoScript.module.scss │ │ ├── ScreenNoScript.stories.tsx │ │ ├── ScreenNoScript.controller.tsx │ │ └── ScreenNoScript.view.tsx │ ├── Nav │ │ ├── index.ts │ │ ├── Nav.controller.tsx │ │ ├── Nav.stories.tsx │ │ └── Nav.module.scss │ ├── PageUnsupported │ │ ├── index.ts │ │ ├── PageUnsupported.module.scss │ │ ├── PageUnsupported.stories.tsx │ │ ├── PageUnsupported.controller.tsx │ │ └── PageUnsupported.view.tsx │ ├── Layout │ │ └── Layout.module.scss │ └── Head │ │ ├── MockFeaturePolicy.tsx │ │ └── MockContentSecurityPolicy.tsx ├── utils │ ├── get-id.ts │ ├── tick.ts │ ├── is-routed-href.ts │ ├── array-ref.ts │ ├── sanitizer.ts │ ├── multi-ref.ts │ ├── sass.ts │ ├── detect-low-power-mode.ts │ ├── get-layout.ts │ ├── set-body-classes.ts │ ├── fonts.ts │ ├── runtime-env.ts │ ├── children-are-equal.ts │ ├── scroll-page.ts │ └── print.ts ├── motion │ ├── effects │ │ ├── fade │ │ │ ├── fadeOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── fadeOut.ts │ │ │ │ └── fadeOut.stories.tsx │ │ │ ├── fadeIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── fadeIn.ts │ │ │ │ └── fadeIn.stories.tsx │ │ │ └── fadeFrom │ │ │ │ ├── fadeFrom.ts │ │ │ │ ├── _.d.ts │ │ │ │ └── fadeFrom.stories.tsx │ │ ├── text │ │ │ ├── textCounter │ │ │ │ ├── _.d.ts │ │ │ │ ├── textCounter.ts │ │ │ │ └── textCounter.stories.tsx │ │ │ ├── textRiseByCharsOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByCharsOut.ts │ │ │ │ └── textRiseByCharsOut.stories.tsx │ │ │ ├── textRiseByLinesOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByLinesOut.ts │ │ │ │ └── textRiseByLinesOut.stories.tsx │ │ │ ├── textRiseByWordsOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByWordsOut.ts │ │ │ │ └── textRiseByWordsOut.stories.tsx │ │ │ ├── textFadeByCharsOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByCharsOut.ts │ │ │ │ └── textFadeByCharsOut.stories.tsx │ │ │ ├── textFadeByLinesOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByLinesOut.ts │ │ │ │ └── textFadeByLinesOut.stories.tsx │ │ │ ├── textFadeByWordsOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByWordsOut.ts │ │ │ │ └── textFadeByWordsOut.stories.tsx │ │ │ ├── textRiseByCharsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByCharsIn.stories.tsx │ │ │ │ └── textRiseByCharsIn.ts │ │ │ ├── textRiseByWordsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByWordsIn.stories.tsx │ │ │ │ └── textRiseByWordsIn.ts │ │ │ ├── textFadeByCharsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByCharsIn.stories.tsx │ │ │ │ └── textFadeByCharsIn.ts │ │ │ ├── textFadeByLinesIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByLinesIn.stories.tsx │ │ │ │ └── textFadeByLinesIn.ts │ │ │ ├── textFadeByWordsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByWordsIn.stories.tsx │ │ │ │ └── textFadeByWordsIn.ts │ │ │ ├── textRiseByLinesIn │ │ │ │ ├── _.d.ts │ │ │ │ └── textRiseByLinesIn.stories.tsx │ │ │ ├── textRiseFadeByLinesIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseFadeByLinesIn.stories.tsx │ │ │ │ └── textRiseFadeByLinesIn.ts │ │ │ ├── textRiseFadeByWordsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseFadeByWordsIn.stories.tsx │ │ │ │ └── textRiseFadeByWordsIn.ts │ │ │ ├── textScrambleByChars │ │ │ │ ├── _.d.ts │ │ │ │ └── textScrambleByChars.stories.tsx │ │ │ ├── textScrambleByLines │ │ │ │ ├── _.d.ts │ │ │ │ └── textScrambleByLines.stories.tsx │ │ │ └── textScrambleByWords │ │ │ │ ├── _.d.ts │ │ │ │ └── textScrambleByWords.stories.tsx │ │ ├── timeline │ │ │ ├── timelineTo │ │ │ │ ├── _.d.ts │ │ │ │ ├── timelineTo.ts │ │ │ │ └── timelineTo.stories.tsx │ │ │ └── timelineFromTo │ │ │ │ ├── _.d.ts │ │ │ │ ├── timelineFromTo.ts │ │ │ │ └── timelineFromTo.stories.tsx │ │ ├── mask │ │ │ ├── maskWipeOut │ │ │ │ ├── _.d.ts │ │ │ │ └── maskWipeOut.stories.tsx │ │ │ └── maskWipeIn │ │ │ │ ├── _.d.ts │ │ │ │ └── maskWipeIn.stories.tsx │ │ └── _effects.d.ts │ ├── rive │ │ ├── Intro.stories.tsx │ │ └── Intro.tsx │ ├── transition │ │ └── transition.context.ts │ └── core │ │ ├── init.ts │ │ └── effect-timeline.ts ├── styles │ ├── shared.scss │ ├── export-vars.module.scss │ └── tailwind.scss ├── data │ ├── types.ts │ └── config.json ├── pages │ ├── index.tsx │ ├── about.tsx │ ├── 404.tsx │ ├── 500.tsx │ ├── unsupported.tsx │ └── _document.tsx ├── hooks │ ├── use-window-visible.ts │ ├── use-click-away.ts │ ├── use-cookie.ts │ ├── use-reduced-motion.ts │ ├── use-local-storage.ts │ ├── use-feature-flags.ts │ ├── use-hash-state.ts │ ├── use-window-size.ts │ ├── use-transition-presence.ts │ ├── use-layout.ts │ ├── use-orientation.ts │ ├── use-refs.ts │ ├── use-low-power-mode.ts │ ├── use-intersection-observer.ts │ └── use-before-unmount.ts ├── store │ ├── animations.slice.ts │ ├── consent.slice.ts │ └── store.ts └── services │ ├── scroll.service.ts │ ├── orientation.service.ts │ ├── visibility.service.ts │ ├── cookie.service.ts │ ├── lock-body-scroll.service.ts │ ├── raf.service.ts │ ├── pointer-move.service.ts │ ├── resize.service.ts │ ├── cms.service.ts │ ├── local-storage.service.ts │ └── analytics.service.ts ├── docs ├── media-queries.md ├── asset-management.md ├── state-management.md ├── configuring-analytics.md ├── environment-variables.md └── import-aliases.md ├── public ├── common │ ├── assets │ │ ├── images │ │ │ ├── .gitkeep │ │ │ └── share-image.jpg │ │ ├── sounds │ │ │ └── .gitkeep │ │ └── videos │ │ │ └── .gitkeep │ └── favicons │ │ ├── favicon.ico │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-144x144.png │ │ ├── favicon-150x150.png │ │ ├── favicon-192x192.png │ │ ├── favicon-384x384.png │ │ ├── favicon-512x512.png │ │ ├── browserconfig.xml │ │ └── site.webmanifest └── robots.txt ├── .prettierignore ├── .eslintignore ├── .npmrc ├── .husky ├── commit-msg ├── pre-commit ├── post-checkout ├── post-commit ├── pre-push └── post-merge ├── postcss.config.js ├── scripts ├── templates │ ├── page.module.scss.hbs │ ├── component.module.scss.hbs │ ├── page.index.ts.hbs │ ├── component.index.ts.hbs │ ├── api.ts.hbs │ ├── route.ts.hbs │ ├── page.stories.tsx.hbs │ ├── page.controller.tsx.hbs │ ├── component.stories.tsx.hbs │ ├── slice.ts.hbs │ ├── component.controller.tsx.hbs │ └── page.view.tsx.hbs ├── fix-lfs-hooks.sh ├── fix-lfs-hooks.ps1 ├── imports-watch.js ├── prepare.js ├── dev-server │ ├── index.js │ └── certificates │ │ └── localhost.crt └── public-image-sizes.js ├── .storybook ├── storybook.scss ├── manager-head.html ├── intro │ ├── Readme.stories.mdx │ ├── Colors.module.scss │ ├── Typography.module.scss │ ├── Colors.stories.tsx │ └── Typography.stories.tsx ├── manager.ts ├── main.ts └── webpack.ts ├── .vscode ├── settings.json └── extensions.json ├── .prettierrc ├── .envrc ├── .env.production ├── next-env.d.ts ├── .circleci ├── orbs │ └── main.yml ├── config.yml ├── scripts │ ├── build.sh │ ├── cache-invalidate.sh │ └── deploy.sh └── workflows │ └── pull-requests.yml ├── .editorconfig ├── types.d.ts ├── .env.development ├── tailwind.config.js ├── .gitignore ├── commitlint.config.js ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── PULL_REQUEST_TEMPLATE.md ├── tsconfig.json ├── lint-staged.config.js ├── LICENSE ├── next.config.js └── next-sitemap.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.19.0 -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/media-queries.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /docs/asset-management.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /docs/state-management.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /public/common/assets/images/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/common/assets/sounds/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/common/assets/videos/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | scripts/templates/* 2 | -------------------------------------------------------------------------------- /docs/configuring-analytics.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /docs/environment-variables.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !.storybook 2 | scripts/templates/* 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # Development configuration 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /src/assets/fonts/.gitkeep: -------------------------------------------------------------------------------- 1 | // Try to use the less amount of custom fonts possibles :-) 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @gsap:registry=https://npm.greensock.com 2 | //npm.greensock.com/:_authToken=${GSAP_NPM_TOKEN} 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /src/components/BaseImage/BaseImage.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | display: block; 5 | } 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /scripts/templates/page.module.scss.hbs: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | width: 100%; 5 | opacity: 0; 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/images/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/src/assets/images/test.png -------------------------------------------------------------------------------- /src/assets/images/x-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/src/assets/images/x-logo.png -------------------------------------------------------------------------------- /src/assets/rive/x-intro.riv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/src/assets/rive/x-intro.riv -------------------------------------------------------------------------------- /.storybook/storybook.scss: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/common/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/favicon.ico -------------------------------------------------------------------------------- /src/assets/images/github-icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/src/assets/images/github-icon-64.png -------------------------------------------------------------------------------- /src/assets/images/npm-icon-64b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/src/assets/images/npm-icon-64b.png -------------------------------------------------------------------------------- /src/utils/get-id.ts: -------------------------------------------------------------------------------- 1 | let id = 0 2 | 3 | export function getId(): string { 4 | return (id++ % Number.MAX_SAFE_INTEGER).toString() 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "scss.lint.unknownAtRules": "ignore", 3 | "typescript.preferences.importModuleSpecifier": "non-relative" 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/images/github-icon-64b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/src/assets/images/github-icon-64b.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "endOfLine": "auto", 4 | "printWidth": 120, 5 | "singleQuote": true, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /public/common/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /src/assets/images/experience-logo-500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/src/assets/images/experience-logo-500.png -------------------------------------------------------------------------------- /src/utils/tick.ts: -------------------------------------------------------------------------------- 1 | export function tick(): Promise { 2 | return new Promise((resolve) => { 3 | setTimeout(resolve, 0) 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export NODE_VERSION_PREFIX=v 2 | export NODE_VERSIONS=~/.nvm/versions/node 3 | NODE_VERSION_TO_USE=$( cat .nvmrc ) 4 | use node ${NODE_VERSION_TO_USE:1} -------------------------------------------------------------------------------- /public/common/assets/images/share-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/assets/images/share-image.jpg -------------------------------------------------------------------------------- /public/common/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/favicon-144x144.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/favicon-150x150.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/favicon-192x192.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/favicon-384x384.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/HEAD/public/common/favicons/favicon-512x512.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | . scripts/fix-lfs-hooks.sh 4 | 5 | echo "Pre-commit checks...." 6 | 7 | npm run lint-staged 8 | -------------------------------------------------------------------------------- /scripts/templates/component.module.scss.hbs: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | display: block; 5 | {{#if transitionPresence}} 6 | opacity: 0; 7 | {{/if}} 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Footer/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as FooterProps } from './Footer.controller' 2 | 3 | export { Controller as Footer } from './Footer.controller' 4 | -------------------------------------------------------------------------------- /src/motion/effects/fade/fadeOut/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | fadeOut: CustomEffect<{ duration: number; reversed: boolean; delay: number; stagger: number }> 3 | } 4 | -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | . scripts/fix-lfs-hooks.sh 4 | . .husky/lfs-hooks/post-checkout $@ 5 | 6 | echo "Post-checkout checks..." 7 | -------------------------------------------------------------------------------- /.husky/post-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | . scripts/fix-lfs-hooks.sh 4 | . .husky/lfs-hooks/post-commit $@ 5 | 6 | echo "Post-commit checks..." 7 | 8 | -------------------------------------------------------------------------------- /src/components/AppAdmin/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as AppAdminProps } from './AppAdmin.controller' 2 | 3 | export { Controller as AppAdmin } from './AppAdmin.controller' 4 | -------------------------------------------------------------------------------- /src/components/PageHome/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as PageHomeProps } from './PageHome.controller' 2 | 3 | export { Controller as PageHome } from './PageHome.controller' 4 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | . scripts/fix-lfs-hooks.sh 4 | . .husky/lfs-hooks/pre-push $@ 5 | 6 | echo "Pre-push checks..." 7 | 8 | npm run lint-dev 9 | -------------------------------------------------------------------------------- /src/components/BaseImage/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as BaseImageProps } from './BaseImage.controller' 2 | 3 | export { Controller as BaseImage } from './BaseImage.controller' 4 | -------------------------------------------------------------------------------- /src/components/PageAbout/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as PageAboutProps } from './PageAbout.controller' 2 | 3 | export { Controller as PageAbout } from './PageAbout.controller' 4 | -------------------------------------------------------------------------------- /src/components/BaseButton/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as BaseButtonProps } from './BaseButton.controller' 2 | 3 | export { Controller as BaseButton } from './BaseButton.controller' 4 | -------------------------------------------------------------------------------- /src/components/ScreenIntro/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as ScreenIntroProps } from './ScreenIntro.controller' 2 | 3 | export { Controller as ScreenIntro } from './ScreenIntro.controller' 4 | -------------------------------------------------------------------------------- /src/components/CookieBanner/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as CookieBannerProps } from './CookieBanner.controller' 2 | 3 | export { Controller as CookieBanner } from './CookieBanner.controller' 4 | -------------------------------------------------------------------------------- /src/components/PageNotFound/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as PageNotFoundProps } from './PageNotFound.controller' 2 | 3 | export { Controller as PageNotFound } from './PageNotFound.controller' 4 | -------------------------------------------------------------------------------- /src/components/ScreenRotate/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as ScreenRotateProps } from './ScreenRotate.controller' 2 | 3 | export { Controller as ScreenRotate } from './ScreenRotate.controller' 4 | -------------------------------------------------------------------------------- /src/components/ScreenNoScript/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as ScreenNoScriptProps } from './ScreenNoScript.controller' 2 | 3 | export { Controller as ScreenNoScript } from './ScreenNoScript.controller' 4 | -------------------------------------------------------------------------------- /src/styles/shared.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:color'; 2 | @use 'sass:list'; 3 | @use 'sass:math'; 4 | @use 'sass:map'; 5 | 6 | @import 'tailwind'; 7 | @import 'mixins'; 8 | @import 'typography'; 9 | @import 'vars'; 10 | -------------------------------------------------------------------------------- /src/components/Nav/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as NavProps } from './Nav.controller' 2 | export type { ViewHandle as NavHandle } from './Nav.view' 3 | 4 | export { Controller as Nav } from './Nav.controller' 5 | -------------------------------------------------------------------------------- /src/components/PageUnsupported/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as PageUnsupportedProps } from './PageUnsupported.controller' 2 | 3 | export { Controller as PageUnsupported } from './PageUnsupported.controller' 4 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | OUTPUT=export 2 | BUNDLE_ANALYZE=false 3 | 4 | NEXT_PUBLIC_COMMIT_ID=$COMMIT_ID 5 | NEXT_PUBLIC_COMMIT_DATE=$COMMIT_DATE 6 | NEXT_PUBLIC_VERSION_NUMBER=$VERSION_NUMBER 7 | 8 | NEXT_PUBLIC_AWS_RUM=$AWS_RUM 9 | -------------------------------------------------------------------------------- /src/data/types.ts: -------------------------------------------------------------------------------- 1 | import type { PageContent, PageIdentifier } from '@/services/cms.service' 2 | 3 | export type PageProps = { 4 | content: PageContent 5 | noLayout?: boolean 6 | } 7 | -------------------------------------------------------------------------------- /src/motion/effects/fade/fadeIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | fadeIn: CustomEffect< 3 | { duration: number; reversed: boolean; delay: number; stagger: number; ease: string }, 4 | gsap.TweenTarget 5 | > 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/components/PageNotFound/PageNotFound.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | position: relative; 5 | width: 100%; 6 | 7 | .title { 8 | @include typography-h1; 9 | padding-top: px(80); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /scripts/templates/page.index.ts.hbs: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as Page{{titleCase name}}Props } from './Page{{titleCase name}}.controller' 2 | 3 | export { Controller as Page{{titleCase name}} } from './Page{{titleCase name}}.controller' 4 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | display: flex; 5 | flex-direction: column; 6 | 7 | .content { 8 | position: relative; 9 | width: 100%; 10 | flex: 1 1 auto; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/PageUnsupported/PageUnsupported.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | position: relative; 5 | width: 100%; 6 | 7 | .title { 8 | @include typography-h1; 9 | padding-top: px(80); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/motion/effects/text/textCounter/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textCounter: CustomEffect<{ 3 | duration: number 4 | delay: number 5 | start: number 6 | end: number 7 | ease: string | gsap.EaseFunction 8 | }> 9 | } 10 | -------------------------------------------------------------------------------- /.circleci/orbs/main.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | aws-cli: circleci/aws-cli@2.1.0 5 | github-cli: circleci/github-cli@2.1.0 6 | slack: circleci/slack@4.10.1 7 | lighthouse-check: foo-software/lighthouse-check@0.0.13 8 | gitleaks: upenn-libraries/gitleaks@0.1.0 9 | -------------------------------------------------------------------------------- /src/components/ScreenIntro/ScreenIntro.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | @include position-100(fixed); 5 | @include z-index(intro); 6 | pointer-events: none; 7 | background: $black; 8 | 9 | &.loaded { 10 | background: transparent; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByCharsOut/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textRiseByCharsOut: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | charDuration: number 6 | charOffset: number 7 | ease: string | gsap.EaseFunction 8 | }> 9 | } 10 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByLinesOut/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textRiseByLinesOut: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | lineDuration: number 6 | lineOffset: number 7 | ease: string | gsap.EaseFunction 8 | }> 9 | } 10 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByWordsOut/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textRiseByWordsOut: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | wordDuration: number 6 | wordOffset: number 7 | ease: string | gsap.EaseFunction 8 | }> 9 | } 10 | -------------------------------------------------------------------------------- /scripts/fix-lfs-hooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ls .husky/lfs-hooks >> /dev/null 2>&1 || ( 3 | rm -rf .git/hooks 4 | git config --unset core.hooksPath 5 | git lfs install 6 | mv .git/hooks .husky/lfs-hooks 7 | rm -rf node_modules/husky 8 | npm install 9 | git lfs pull 10 | ) -------------------------------------------------------------------------------- /src/motion/effects/timeline/timelineTo/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | timelineTo: CustomEffect< 3 | { 4 | reversed: boolean 5 | duration: number 6 | ease: string | gsap.EaseFunction 7 | to: number 8 | }, 9 | gsap.core.Timeline 10 | > 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_size = 2 7 | end_of_line = lf 8 | indent_style = space 9 | max_line_length = 120 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /src/motion/effects/mask/maskWipeOut/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | maskWipeOut: CustomEffect<{ 3 | direction: 'left' | 'right' | 'up' | 'down' 4 | duration: number 5 | reversed: boolean 6 | stagger: number 7 | offset: number 8 | ease: string | gsap.EaseFunction 9 | }> 10 | } 11 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByCharsOut/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textFadeByCharsOut: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | charDuration: number 6 | charOffset: number 7 | shuffle: boolean 8 | ease: string | gsap.EaseFunction 9 | }> 10 | } 11 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByLinesOut/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textFadeByLinesOut: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | lineDuration: number 6 | lineOffset: number 7 | shuffle: boolean 8 | ease: string | gsap.EaseFunction 9 | }> 10 | } 11 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByWordsOut/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textFadeByWordsOut: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | wordDuration: number 6 | wordOffset: number 7 | shuffle: boolean 8 | ease: string | gsap.EaseFunction 9 | }> 10 | } 11 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByCharsIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textRiseByCharsIn: CustomEffect<{ 3 | immediateRender: boolean 4 | duration: number 5 | reversed: boolean 6 | charDuration: number 7 | charOffset: number 8 | ease: string | gsap.EaseFunction 9 | }> 10 | } 11 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByWordsIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textRiseByWordsIn: CustomEffect<{ 3 | immediateRender: boolean 4 | duration: number 5 | reversed: boolean 6 | wordDuration: number 7 | wordOffset: number 8 | ease: string | gsap.EaseFunction 9 | }> 10 | } 11 | -------------------------------------------------------------------------------- /src/motion/effects/fade/fadeIn/fadeIn.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | const effect: CustomEffectConfig = { 4 | name: 'fadeIn', 5 | effect: (target, config = {}) => { 6 | return gsap.timeline().to(target, { ...config, autoAlpha: 1 }) 7 | }, 8 | extendTimeline: true 9 | } 10 | 11 | export default effect 12 | -------------------------------------------------------------------------------- /src/motion/effects/fade/fadeOut/fadeOut.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | const effect: CustomEffectConfig = { 4 | name: 'fadeOut', 5 | effect: (target, config = {}) => { 6 | return gsap.timeline().to(target, { ...config, autoAlpha: 0 }) 7 | }, 8 | extendTimeline: true 9 | } 10 | 11 | export default effect 12 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | setup: true 4 | 5 | orbs: 6 | split-config: bufferings/split-config@0.1.0 # NOTE: https://github.com/bufferings/orb-split-config 7 | 8 | workflows: 9 | generate-config: 10 | jobs: 11 | - split-config/generate-config: 12 | find-config-regex: .*/\.circleci/*.*\.yml 13 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | background-color: $black; 5 | color: $white; 6 | width: 100%; 7 | padding: px(50) 0; 8 | 9 | ul { 10 | li { 11 | margin-top: px(15); 12 | 13 | &:first-child { 14 | margin-top: 0; 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "yoavbls.pretty-ts-errors", 6 | "stylelint.vscode-stylelint", 7 | "mikestead.dotenv", 8 | "syler.sass-indented", 9 | "mrmlnc.vscode-scss", 10 | "clinyong.vscode-css-modules" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /public/common/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffc40d 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByCharsIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textFadeByCharsIn: CustomEffect<{ 3 | immediateRender: boolean 4 | duration: number 5 | reversed: boolean 6 | charDuration: number 7 | charOffset: number 8 | shuffle: boolean 9 | ease: string | gsap.EaseFunction 10 | }> 11 | } 12 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByLinesIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textFadeByLinesIn: CustomEffect<{ 3 | immediateRender: boolean 4 | duration: number 5 | reversed: boolean 6 | lineDuration: number 7 | lineOffset: number 8 | shuffle: boolean 9 | ease: string | gsap.EaseFunction 10 | }> 11 | } 12 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByWordsIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textFadeByWordsIn: CustomEffect<{ 3 | immediateRender: boolean 4 | duration: number 5 | reversed: boolean 6 | wordDuration: number 7 | wordOffset: number 8 | shuffle: boolean 9 | ease: string | gsap.EaseFunction 10 | }> 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/export-vars.module.scss: -------------------------------------------------------------------------------- 1 | /* stylelint-disable */ 2 | @import 'shared'; 3 | 4 | :export { 5 | // breakpoints 6 | breakpoint-mobile: $breakpoint-mobile; 7 | breakpoint-tablet: $breakpoint-tablet; 8 | breakpoint-desktop: $breakpoint-desktop; 9 | // color 10 | color-white: $white; 11 | color-black: $black; 12 | } 13 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | CustomEase: any 4 | trustedTypes: any 5 | fbAsyncInit: any 6 | dataLayer: object[] 7 | MSStream: any 8 | safari: any 9 | fbq: any 10 | FB: any 11 | WM: any 12 | /* eslint-enable @typescript-eslint/no-explicit-any */ 13 | } 14 | -------------------------------------------------------------------------------- /src/motion/effects/mask/maskWipeIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | maskWipeIn: CustomEffect<{ 3 | immediateRender: boolean 4 | direction: 'left' | 'right' | 'up' | 'down' 5 | duration: number 6 | reversed: boolean 7 | stagger: number 8 | offset: number 9 | ease: string | gsap.EaseFunction 10 | }> 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/is-routed-href.ts: -------------------------------------------------------------------------------- 1 | import type { UrlObject } from 'node:url' 2 | 3 | export function isRoutedHref(href?: string | UrlObject, download = false) { 4 | if (!href) return false 5 | const pathname = typeof href === 'string' ? href : href?.pathname || '' 6 | return (pathname?.startsWith('#') || pathname?.startsWith('/')) && !download 7 | } 8 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByLinesIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textRiseByLinesIn: CustomEffect<{ 3 | revertOnComplete: boolean 4 | immediateRender: boolean 5 | duration: number 6 | reversed: boolean 7 | lineDuration: number 8 | lineOffset: number 9 | ease: string | gsap.EaseFunction 10 | }> 11 | } 12 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseFadeByLinesIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textRiseFadeByLinesIn: CustomEffect<{ 3 | immediateRender: boolean 4 | duration: number 5 | reversed: boolean 6 | lineDuration: number 7 | lineOffset: number 8 | ease: string | gsap.EaseFunction 9 | y: string | number 10 | }> 11 | } 12 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseFadeByWordsIn/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textRiseFadeByWordsIn: CustomEffect<{ 3 | immediateRender: boolean 4 | duration: number 5 | reversed: boolean 6 | wordDuration: number 7 | wordOffset: number 8 | ease: string | gsap.EaseFunction 9 | y: string | number 10 | }> 11 | } 12 | -------------------------------------------------------------------------------- /src/motion/effects/timeline/timelineFromTo/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | timelineFromTo: CustomEffect< 3 | { 4 | duration: number 5 | reversed: boolean 6 | from: number 7 | to: number 8 | ease: string | gsap.EaseFunction 9 | }, 10 | gsap.core.Timeline | (() => gsap.core.Timeline) 11 | > 12 | } 13 | -------------------------------------------------------------------------------- /scripts/templates/component.index.ts.hbs: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as {{titleCase name}}Props } from './{{titleCase name}}.controller' 2 | {{#if imperativeHandle}} 3 | export type { ViewHandle as {{titleCase name}}Handle } from './{{titleCase name}}.view' 4 | {{/if}} 5 | 6 | export { Controller as {{titleCase name}} } from './{{titleCase name}}.controller' 7 | -------------------------------------------------------------------------------- /scripts/fix-lfs-hooks.ps1: -------------------------------------------------------------------------------- 1 | if (-Not (Test-Path ".husky\lfs-hooks")) { 2 | Remove-Item -Path ".git\hooks" -Recurse -Force 3 | git config --unset core.hooksPath 4 | git lfs install 5 | Move-Item -Path ".git\hooks" -Destination ".husky\lfs-hooks" 6 | Remove-Item -Path "node_modules\husky" -Recurse -Force 7 | npm install 8 | git lfs pull 9 | } -------------------------------------------------------------------------------- /.storybook/intro/Readme.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs' 2 | import { Markdown } from '@storybook/blocks' 3 | 4 | import Readme from '../../README.md?raw' 5 | 6 | 7 | 8 | 9 | {Readme.replaceAll('./docs/', 'https://github.com/Experience-Monks/nextjs-boilerplate/blob/main/docs/')} 10 | 11 | -------------------------------------------------------------------------------- /src/styles/tailwind.scss: -------------------------------------------------------------------------------- 1 | // Add tailwind-related mixins here 2 | 3 | @mixin tailwind-example { 4 | @apply fixed 5 | top-1/2 6 | left-1/2 7 | -translate-x-1/2 8 | -translate-y-1/2 9 | w-[50rem] 10 | h-[50rem] 11 | bg-[#f00] 12 | flex 13 | items-center 14 | justify-center 15 | border 16 | border-blue-600; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/ScreenNoScript/ScreenNoScript.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | @include position-100(fixed); 5 | @include z-index(noscript); 6 | @include flex-center; 7 | flex-direction: column; 8 | color: $black; 9 | background-color: $white; 10 | 11 | .title { 12 | @include typography-h1; 13 | margin: 0 0 px(20); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/ScreenRotate/ScreenRotate.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | @include position-100(fixed); 5 | @include z-index(nonfunctional); 6 | @include flex-center; 7 | flex-direction: column; 8 | color: $white; 9 | background-color: $black; 10 | 11 | .title { 12 | @include typography-h1; 13 | margin: 0 0 px(20); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/motion/rive/Intro.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { Intro } from './Intro' 4 | 5 | export default { title: 'motion/Rive/Intro' } 6 | 7 | export const Default: StoryFn = () => { 8 | return ( 9 |
10 | 11 |
12 | ) 13 | } 14 | Default.args = {} 15 | -------------------------------------------------------------------------------- /src/utils/array-ref.ts: -------------------------------------------------------------------------------- 1 | import type { MutableRefObject } from 'react' 2 | 3 | export function arrayRef>( 4 | ref: T, 5 | index: number 6 | ): (element: NonNullable[number] | null) => void { 7 | return (element) => { 8 | if (!ref.current) ref.current = [] 9 | ref.current[index] = element 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.circleci/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | export COMMIT_ID=$(git log --pretty="%h" --no-merges -1) 5 | export COMMIT_DATE="$(git log --date=format:'%Y-%m-%d %H:%M' --pretty="%cd" --no-merges -1)" 6 | 7 | echo "ARTIFACT VERSION $VERSION_NUMBER" 8 | 9 | rm -rf ./out 10 | 11 | npm run build:next 12 | 13 | echo "$CIRCLE_SHA1/$CIRCLE_BUILD_NUM" > out/VERSION.txt 14 | -------------------------------------------------------------------------------- /src/motion/transition/transition.context.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react' 2 | import type { BeforeUnmountCallback } from '@/hooks/use-before-unmount' 3 | 4 | import { createContext } from 'react' 5 | 6 | export type TransitionContextType = Set> | undefined 7 | 8 | export const TransitionContext = createContext(undefined) 9 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | . scripts/fix-lfs-hooks.sh 4 | . .husky/lfs-hooks/post-merge $@ 5 | 6 | echo "Post-merge checks..." 7 | 8 | changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)" 9 | 10 | if echo "$changed_files" | grep --quiet --extended-regexp 'package.json|package-lock.json'; then 11 | npm install 12 | fi 13 | -------------------------------------------------------------------------------- /.circleci/scripts/cache-invalidate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | aws configure set preview.cloudfront true 5 | 6 | INVALIDATION_ID=$(aws cloudfront create-invalidation \ 7 | --distribution-id $DISTRIBUTION_ID \ 8 | --paths '/*' | jq -r '.Invalidation.Id'); 9 | 10 | aws cloudfront wait invalidation-completed \ 11 | --distribution-id $DISTRIBUTION_ID \ 12 | --id $INVALIDATION_ID 13 | -------------------------------------------------------------------------------- /src/utils/sanitizer.ts: -------------------------------------------------------------------------------- 1 | import type { IFilterXSSOptions } from 'xss' 2 | 3 | import xss from 'xss' 4 | 5 | /** 6 | * DOM Sanitizer to protect against untrust inputs and XSS attacks 7 | * 8 | * @param {string} [dirtyInput=''] - Input to sanitize 9 | */ 10 | export function sanitizer(dirtyInput: string, options?: IFilterXSSOptions): string { 11 | return xss(dirtyInput, options) 12 | } 13 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api' 2 | import { create } from '@storybook/theming/create' 3 | 4 | addons.setConfig({ 5 | theme: create({ 6 | base: 'dark', 7 | brandTitle: 'Experience.Monks NextJS Boilerplate', 8 | brandUrl: 'https://media.monks.com/solutions/experience', 9 | brandImage: '/common/favicons/favicon-32x32.png' 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /scripts/imports-watch.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path') 2 | const chokidar = require('chokidar') 3 | 4 | const assetsDir = path.join(__dirname, '../src/assets/') 5 | const svgsDir = path.join(__dirname, '../src/svgs/') 6 | const generate = require('./imports-generate').default 7 | 8 | chokidar.watch([assetsDir, svgsDir], { ignoreInitial: true }).on('all', () => { 9 | generate() 10 | }) 11 | -------------------------------------------------------------------------------- /src/motion/effects/fade/fadeFrom/fadeFrom.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | const effect: CustomEffectConfig = { 4 | name: 'fadeFrom', 5 | effect: (target, config) => { 6 | return gsap.timeline().from(target, { ...config, opacity: 0 }) 7 | }, 8 | defaults: { 9 | duration: +(gsap.defaults().duration || 1) 10 | }, 11 | extendTimeline: true 12 | } 13 | 14 | export default effect 15 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | BUNDLE_ANALYZE=false 2 | 3 | NEXT_PUBLIC_COMMIT_ID="-" 4 | NEXT_PUBLIC_COMMIT_DATE="-" 5 | NEXT_PUBLIC_VERSION_NUMBER="-" 6 | 7 | NEXT_PUBLIC_AWS_RUM="-" # Example: '{"guestRoleArn":"arn:aws:iam::12345:role/cognito_unauthenticated_role","identityPoolId":"us-east-1:xxxxxx-xxxxx-xxxxx","endpoint":"https://dataplane.rum.us-east-1.amazonaws.com","region":"us-east-1","appId":"xxxxxxx-xxxx-xxxx-xxxx-xxxxxx","appVersion":"1.0.0"}' 8 | -------------------------------------------------------------------------------- /src/utils/multi-ref.ts: -------------------------------------------------------------------------------- 1 | import type { ForwardedRef, MutableRefObject } from 'react' 2 | 3 | export function multiRef( 4 | ...refs: (MutableRefObject | ForwardedRef | ((r: T) => void))[] 5 | ): (element: unknown) => void { 6 | return (element) => { 7 | refs.forEach((ref) => { 8 | if (typeof ref === 'function') ref(element as T) 9 | else if (ref) ref.current = element as T 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /scripts/templates/api.ts.hbs: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 4 | if (req.method === 'GET') { 5 | // Process a GET request 6 | res.status(200).json({ name: 'experience.monks' }); 7 | } else if (req.method === 'POST') { 8 | // Process a POST request 9 | } else { 10 | // Handle any other HTTP method 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/motion/effects/text/textScrambleByChars/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textScrambleByChars: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | offset: number 6 | text: string 7 | chars: string 8 | speed: number 9 | immediateRender: boolean 10 | wipe: { 11 | direction: 'left' | 'right' 12 | duration?: number 13 | ease?: string | gsap.EaseFunction 14 | } 15 | }> 16 | } 17 | -------------------------------------------------------------------------------- /src/motion/effects/text/textScrambleByLines/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textScrambleByLines: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | offset: number 6 | text: string 7 | chars: string 8 | speed: number 9 | immediateRender: boolean 10 | wipe: { 11 | direction: 'left' | 'right' 12 | duration?: number 13 | ease?: string | gsap.EaseFunction 14 | } 15 | }> 16 | } 17 | -------------------------------------------------------------------------------- /src/motion/effects/text/textScrambleByWords/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | textScrambleByWords: CustomEffect<{ 3 | duration: number 4 | reversed: boolean 5 | offset: number 6 | text: string 7 | chars: string 8 | speed: number 9 | immediateRender: boolean 10 | wipe: { 11 | direction: 'left' | 'right' 12 | duration?: number 13 | ease?: string | gsap.EaseFunction 14 | } 15 | }> 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { PageHomeProps } from '@/components/PageHome' 2 | import type { GetStaticProps } from 'next' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | export const getStaticProps: GetStaticProps = async () => { 7 | return { 8 | props: { 9 | content: CmsService.getPageContent('home') 10 | } 11 | } 12 | } 13 | 14 | export { PageHome as default } from '@/components/PageHome' 15 | -------------------------------------------------------------------------------- /src/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import type { PageAboutProps } from '@/components/PageAbout' 2 | import type { GetStaticProps } from 'next' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | export const getStaticProps: GetStaticProps = async () => { 7 | return { 8 | props: { 9 | content: CmsService.getPageContent('about') 10 | } 11 | } 12 | } 13 | 14 | export { PageAbout as default } from '@/components/PageAbout' 15 | -------------------------------------------------------------------------------- /src/utils/sass.ts: -------------------------------------------------------------------------------- 1 | import * as vars from '../styles/export-vars.module.scss' 2 | 3 | export const sass = vars.default as unknown as { [key: string]: string } 4 | 5 | export const colors = Object.values(sass) 6 | .filter((value) => value.startsWith('#')) 7 | .reduce<{ [key: string]: string }>((acc, entry) => { 8 | acc[entry[1]] = entry[0] 9 | return acc 10 | }, {}) 11 | 12 | // Usage: 13 | // sass['white'] 14 | // sass['black'] 15 | // etc... 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | // https://tailwindcss.com/docs/configuration 2 | // https://tailwindcss.com/docs/theme 3 | 4 | module.exports = { 5 | content: ['./src/**/*.{js,ts,jsx,tsx,mdx}', './.storybook/**/*.{js,ts,jsx,tsx,mdx}'] 6 | // theme: { 7 | // extend: { 8 | // gridTemplateColumns: { 9 | // retrievers: '0.7fr 1fr auto' 10 | // } 11 | // } 12 | // }, 13 | // plugins: [require('@tailwindcss/typography'), require('daisyui')] 14 | } 15 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import type { PageNotFoundProps } from '@/components/PageNotFound' 2 | import type { GetStaticProps } from 'next' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | export const getStaticProps: GetStaticProps = async () => { 7 | return { 8 | props: { 9 | content: CmsService.getPageContent('notFound'), 10 | noLayout: true 11 | } 12 | } 13 | } 14 | 15 | export { PageNotFound as default } from '@/components/PageNotFound' 16 | -------------------------------------------------------------------------------- /src/pages/500.tsx: -------------------------------------------------------------------------------- 1 | import type { PageNotFoundProps } from '@/components/PageNotFound' 2 | import type { GetStaticProps } from 'next' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | export const getStaticProps: GetStaticProps = async () => { 7 | return { 8 | props: { 9 | content: CmsService.getPageContent('notFound'), 10 | noLayout: true 11 | } 12 | } 13 | } 14 | 15 | export { PageNotFound as default } from '@/components/PageNotFound' 16 | -------------------------------------------------------------------------------- /src/components/AppAdmin/AppAdmin.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './AppAdmin.view' 3 | 4 | import { View } from './AppAdmin.view' 5 | 6 | export default { title: 'components/AppAdmin' } 7 | 8 | export const Default: StoryFn = (args) => { 9 | return 10 | } 11 | 12 | Default.args = { 13 | env: 'storybook', 14 | date: '2021-01-01 15:03', 15 | commit: 'bae8179', 16 | version: '123' 17 | } 18 | 19 | Default.argTypes = {} 20 | -------------------------------------------------------------------------------- /src/components/PageHome/PageHome.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './PageHome.view' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | import { View } from './PageHome.view' 7 | 8 | export default { title: 'pages/PageHome' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = { 15 | content: CmsService.getPageContent('home') 16 | } 17 | 18 | Default.argTypes = {} 19 | -------------------------------------------------------------------------------- /src/components/ScreenIntro/ScreenIntro.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './ScreenIntro.view' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | 6 | import { View } from './ScreenIntro.view' 7 | 8 | export default { title: 'components/ScreenIntro' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = {} 15 | 16 | Default.argTypes = {} 17 | -------------------------------------------------------------------------------- /src/motion/effects/fade/fadeFrom/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | fadeFrom: CustomEffect< 3 | { 4 | duration: number 5 | reversed: boolean 6 | delay: number 7 | stagger: number 8 | ease: string | gsap.EaseFunction 9 | x: number | string 10 | y: number | string 11 | xPercent: number 12 | yPercent: number 13 | marginTop: number | string 14 | clearProps: string 15 | immediateRender: boolean 16 | }, 17 | gsap.TweenTarget 18 | > 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/unsupported.tsx: -------------------------------------------------------------------------------- 1 | import type { PageUnsupportedProps } from '@/components/PageUnsupported' 2 | import type { GetStaticProps } from 'next' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | export const getStaticProps: GetStaticProps = async () => { 7 | return { 8 | props: { 9 | content: CmsService.getPageContent('unsupported'), 10 | noLayout: true 11 | } 12 | } 13 | } 14 | 15 | export { PageUnsupported as default } from '@/components/PageUnsupported' 16 | -------------------------------------------------------------------------------- /.storybook/intro/Colors.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | display: grid; 5 | grid-template-columns: 1fr; 6 | grid-template-rows: 1fr 1fr; 7 | row-gap: 10px; 8 | color: $black; 9 | 10 | @include media-tablet { 11 | gap: 20px; 12 | grid-template-columns: 1fr 1fr; 13 | } 14 | 15 | .item { 16 | .color { 17 | height: 50px; 18 | margin-bottom: 5px; 19 | border: 1px dashed magenta; 20 | } 21 | 22 | .label { 23 | font-family: monospace; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './Footer.view' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | import { View } from './Footer.view' 7 | 8 | export default { title: 'components/Footer' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = { 15 | content: CmsService.getPageContent('home').common.footer 16 | } 17 | 18 | Default.argTypes = {} 19 | -------------------------------------------------------------------------------- /src/components/PageAbout/PageAbout.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './PageAbout.view' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | import { View } from './PageAbout.view' 7 | 8 | export default { title: 'pages/PageAbout' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = { 15 | content: CmsService.getPageContent('about') 16 | } 17 | 18 | Default.argTypes = {} 19 | -------------------------------------------------------------------------------- /scripts/templates/route.ts.hbs: -------------------------------------------------------------------------------- 1 | import type { Page{{titleCase name}}Props } from '@/components/Page{{titleCase name}}' 2 | import type { GetStaticProps } from 'next' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | export const getStaticProps: GetStaticProps = async () => { 7 | return { 8 | props: { 9 | content: CmsService.getPageContent('{{camelCase name}}') 10 | } 11 | } 12 | } 13 | 14 | export { Page{{titleCase name}} as default } from '@/components/Page{{titleCase name}}' 15 | -------------------------------------------------------------------------------- /src/components/BaseImage/BaseImage.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './BaseImage.view' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | 6 | import { View } from './BaseImage.view' 7 | 8 | export default { title: 'components/BaseImage' } 9 | 10 | export const Default: StoryFn = (args) => 11 | 12 | Default.args = { 13 | data: require('@/assets/images/test.png').default, 14 | onLoad: action('onLoad') 15 | } 16 | 17 | Default.argTypes = {} 18 | -------------------------------------------------------------------------------- /src/components/PageNotFound/PageNotFound.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './PageNotFound.view' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | import { View } from './PageNotFound.view' 7 | 8 | export default { title: 'pages/PageNotFound.view' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = { 15 | content: CmsService.getPageContent('notFound') 16 | } 17 | 18 | Default.argTypes = {} 19 | -------------------------------------------------------------------------------- /scripts/templates/page.stories.tsx.hbs: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './Page{{titleCase name}}.view' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | import { View } from './Page{{titleCase name}}.view' 7 | 8 | export default { title: 'pages/Page{{titleCase name}}' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = {} 15 | 16 | Default.argTypes = {} 17 | -------------------------------------------------------------------------------- /src/components/PageHome/PageHome.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { PageProps } from '@/data/types' 3 | 4 | import { memo } from 'react' 5 | 6 | import { View } from './PageHome.view' 7 | 8 | export interface ControllerProps extends PageProps<'home'> {} 9 | 10 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 11 | export const Controller: FC = memo((props) => { 12 | return 13 | }) 14 | 15 | Controller.displayName = 'PageHome_Controller' 16 | -------------------------------------------------------------------------------- /scripts/prepare.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('node:child_process') 2 | const os = require('node:os') 3 | 4 | const isWindows = os.platform() === 'win32' 5 | if (isWindows) { 6 | try { 7 | execSync('powershell -File scripts/fix-lfs-hooks.ps1', { stdio: 'inherit' }) 8 | } catch { 9 | console.error('PowerShell (pwsh) not found, Please install it and run the script again.') 10 | // eslint-disable-next-line unicorn/no-process-exit 11 | process.exit(1) 12 | } 13 | } else { 14 | execSync('sh scripts/fix-lfs-hooks.sh', { stdio: 'inherit' }) 15 | } 16 | -------------------------------------------------------------------------------- /src/components/PageAbout/PageAbout.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { PageProps } from '@/data/types' 3 | 4 | import { memo } from 'react' 5 | 6 | import { View } from './PageAbout.view' 7 | 8 | export interface ControllerProps extends PageProps<'about'> {} 9 | 10 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 11 | export const Controller: FC = memo((props) => { 12 | return 13 | }) 14 | 15 | Controller.displayName = 'PageAbout_Controller' 16 | -------------------------------------------------------------------------------- /src/components/PageUnsupported/PageUnsupported.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './PageUnsupported.view' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | import { View } from './PageUnsupported.view' 7 | 8 | export default { title: 'pages/PageUnsupported' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = { 15 | content: CmsService.getPageContent('unsupported') 16 | } 17 | 18 | Default.argTypes = {} 19 | -------------------------------------------------------------------------------- /src/components/ScreenRotate/ScreenRotate.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './ScreenRotate.view' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | import { View } from './ScreenRotate.view' 7 | 8 | export default { title: 'components/ScreenRotate' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = { 15 | content: CmsService.getPageContent('home').common.screenRotate 16 | } 17 | 18 | Default.argTypes = {} 19 | -------------------------------------------------------------------------------- /src/components/PageNotFound/PageNotFound.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { PageProps } from '@/data/types' 3 | 4 | import { memo } from 'react' 5 | 6 | import { View } from './PageNotFound.view' 7 | 8 | export interface ControllerProps extends PageProps<'notFound'> {} 9 | 10 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 11 | export const Controller: FC = memo((props) => { 12 | return 13 | }) 14 | 15 | Controller.displayName = 'PageNotFound_Controller' 16 | -------------------------------------------------------------------------------- /src/components/ScreenNoScript/ScreenNoScript.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './ScreenNoScript.view' 3 | 4 | import { CmsService } from '@/services/cms.service' 5 | 6 | import { View } from './ScreenNoScript.view' 7 | 8 | export default { title: 'components/ScreenNoScript' } 9 | 10 | export const Default: StoryFn = (args) => { 11 | return 12 | } 13 | 14 | Default.args = { 15 | content: CmsService.getPageContent('home').common.screenNoScript 16 | } 17 | 18 | Default.argTypes = {} 19 | -------------------------------------------------------------------------------- /scripts/templates/page.controller.tsx.hbs: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { PageProps } from '@/data/types' 3 | 4 | import { memo } from 'react' 5 | 6 | import { View } from './Page{{titleCase name}}.view' 7 | 8 | export interface ControllerProps extends PageProps<'{{camelCase name}}'> {} 9 | 10 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 11 | export const Controller: FC = memo((props) => { 12 | return 13 | }) 14 | 15 | Controller.displayName = 'Page{{titleCase name}}_Controller' 16 | -------------------------------------------------------------------------------- /src/components/PageUnsupported/PageUnsupported.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { PageProps } from '@/data/types' 3 | 4 | import { memo } from 'react' 5 | 6 | import { View } from './PageUnsupported.view' 7 | 8 | export interface ControllerProps extends PageProps<'unsupported'> {} 9 | 10 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 11 | export const Controller: FC = memo((props) => { 12 | return 13 | }) 14 | 15 | Controller.displayName = 'PageUnsupported_Controller' 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | /out-storybook/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | .generated 23 | .husky/_ 24 | .husky/lfs-hooks 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | -------------------------------------------------------------------------------- /docs/import-aliases.md: -------------------------------------------------------------------------------- 1 | # Import Aliases 2 | 3 | We've set up import aliases to simplify file referencing: 4 | 5 | - `@/` represents the `./src/` folder 6 | - `#/` represents the `./generated/` folder 7 | 8 | ## Usage Examples 9 | 10 | ### In TypeScript/JavaScript: 11 | 12 | ```tsx 13 | import { Svgs } from '#/svg-imports' 14 | import { print } from '@/utils/print' 15 | ``` 16 | 17 | These imports resolve to: 18 | 19 | - `./generated/svg-imports.ts` 20 | - `./src/utils/print` 21 | 22 | ### In SCSS: 23 | 24 | ```scss 25 | src: url('~@/assets/fonts/ShopifySans/ShopifySans-Regular.woff2'); 26 | ``` 27 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { CommonContent } from '@/services/cms.service' 3 | 4 | import { memo } from 'react' 5 | 6 | import { View } from './Footer.view' 7 | 8 | export interface ControllerProps { 9 | className?: string 10 | content: CommonContent['footer'] 11 | } 12 | 13 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 14 | export const Controller: FC = memo((props) => { 15 | return 16 | }) 17 | 18 | Controller.displayName = 'Footer_Controller' 19 | -------------------------------------------------------------------------------- /src/components/PageAbout/PageAbout.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | position: relative; 5 | width: 100%; 6 | 7 | .title { 8 | @include typography-h1; 9 | padding-top: px(80); 10 | width: px(240); 11 | margin: 0 auto; 12 | } 13 | 14 | .description { 15 | max-width: px(720); 16 | margin: 0 auto; 17 | padding: px(80) px(20); 18 | text-align: left; 19 | 20 | h2 { 21 | margin: 0 0 px(40); 22 | } 23 | 24 | h3 { 25 | margin: 0 0 px(30); 26 | } 27 | 28 | p, 29 | li { 30 | margin: 0 0 px(20); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/nextjs' 2 | 3 | import { webpackFinal } from './webpack' 4 | 5 | const config: StorybookConfig = { 6 | stories: ['./**/*.mdx', './**/*.stories.@(js|jsx|ts|tsx)', '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 7 | addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], 8 | framework: { 9 | name: '@storybook/nextjs', 10 | options: {} 11 | }, 12 | docs: { 13 | autodocs: 'tag' 14 | }, 15 | staticDirs: ['../public', '../docs'], 16 | webpackFinal 17 | } 18 | export default config 19 | -------------------------------------------------------------------------------- /src/components/ScreenRotate/ScreenRotate.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { CommonContent } from '@/services/cms.service' 3 | 4 | import { memo } from 'react' 5 | 6 | import { View } from './ScreenRotate.view' 7 | 8 | export interface ControllerProps { 9 | className?: string 10 | content: CommonContent['screenRotate'] 11 | } 12 | 13 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 14 | export const Controller: FC = memo((props) => { 15 | return 16 | }) 17 | 18 | Controller.displayName = 'ScreenRotate_Controller' 19 | -------------------------------------------------------------------------------- /src/motion/effects/timeline/timelineTo/timelineTo.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | const effect: CustomEffectConfig = { 4 | name: 'timelineTo', 5 | effect: (target, config = {}) => { 6 | const tl = (target as unknown as gsap.core.Timeline[])[0] 7 | return gsap.timeline().add( 8 | tl.tweenTo(tl.duration() * (config.to || 1), { 9 | duration: config.duration, 10 | ease: config.ease 11 | }) 12 | ) 13 | }, 14 | defaults: { 15 | to: 1, 16 | ease: 'none', 17 | duration: +(gsap.defaults().duration || 1) 18 | }, 19 | extendTimeline: true 20 | } 21 | 22 | export default effect 23 | -------------------------------------------------------------------------------- /src/hooks/use-window-visible.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { VisibilityService } from '@/services/visibility.service' 4 | 5 | export const useWindowVisible = () => { 6 | const [visible, setVisible] = useState(true) 7 | 8 | useEffect(() => { 9 | const update = (e: Event) => { 10 | if (e && e.type === 'blur') { 11 | setVisible(false) 12 | } else { 13 | setVisible(!document.hidden) 14 | } 15 | } 16 | 17 | VisibilityService.listen(update) 18 | 19 | return () => { 20 | VisibilityService.dismiss(update) 21 | } 22 | }, []) 23 | 24 | return visible 25 | } 26 | -------------------------------------------------------------------------------- /public/common/favicons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "React App", 3 | "short_name": "React App", 4 | "icons": [ 5 | { 6 | "src": "/common/favicons/favicon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/common/favicons/favicon-384x384.png", 12 | "sizes": "384x384", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/common/favicons/favicon-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "start_url": "/", 22 | "theme_color": "#000000", 23 | "background_color": "#ffffff", 24 | "display": "standalone" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/CookieBanner/CookieBanner.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './CookieBanner.view' 3 | 4 | import { action } from '@storybook/addon-actions' 5 | 6 | import { CmsService } from '@/services/cms.service' 7 | 8 | import { View } from './CookieBanner.view' 9 | 10 | export default { title: 'components/CookieBanner' } 11 | 12 | export const Default: StoryFn = (args) => { 13 | return 14 | } 15 | 16 | Default.args = { 17 | content: CmsService.getPageContent('home').common.cookieBanner 18 | } 19 | 20 | Default.argTypes = {} 21 | -------------------------------------------------------------------------------- /src/store/animations.slice.ts: -------------------------------------------------------------------------------- 1 | import type { AppState, Mutators } from './store' 2 | import type { StateCreator } from 'zustand' 3 | 4 | export type AnimationsSliceState = { 5 | animations: { 6 | // getters 7 | introComplete: boolean 8 | // setters 9 | setIntroComplete: (introComplete: boolean) => void 10 | } 11 | } 12 | 13 | export const AnimationsSlice: StateCreator = (set) => ({ 14 | animations: { 15 | introComplete: !!process.env.STORYBOOK, 16 | 17 | setIntroComplete: (introComplete) => { 18 | set((state) => { 19 | state.animations.introComplete = introComplete 20 | }) 21 | } 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /src/components/Nav/Nav.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ForwardedRef } from 'react' 2 | import type { CommonContent } from '@/services/cms.service' 3 | import type { ViewHandle } from './Nav.view' 4 | 5 | import { memo } from 'react' 6 | 7 | import { View } from './Nav.view' 8 | 9 | export interface ControllerProps { 10 | className?: string 11 | handleRef?: ForwardedRef 12 | content: CommonContent['nav'] 13 | } 14 | 15 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 16 | export const Controller: FC = memo((props) => { 17 | return 18 | }) 19 | 20 | Controller.displayName = 'Nav_Controller' 21 | -------------------------------------------------------------------------------- /src/utils/detect-low-power-mode.ts: -------------------------------------------------------------------------------- 1 | import { os } from '@/utils/detect' 2 | 3 | // battery API is not supported for iOS Safari - here is the hack 4 | export const getLowPowerMode = async () => { 5 | if (window.location.host.includes('localhost')) return false 6 | if (!os.ios) return false 7 | 8 | let video: HTMLVideoElement | null = document.createElement('video') 9 | video.setAttribute('playsinline', 'playsinline') 10 | video.setAttribute('src', '') 11 | 12 | try { 13 | await video.play() 14 | } catch (error) { 15 | if ((error as Error).name === 'NotAllowedError') { 16 | return true 17 | } 18 | } finally { 19 | video = null 20 | } 21 | 22 | return false 23 | } 24 | -------------------------------------------------------------------------------- /scripts/templates/component.stories.tsx.hbs: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { {{#if imperativeHandle}}ViewHandle, {{/if}}ViewProps } from './{{titleCase name}}.view' 3 | 4 | {{#if imperativeHandle}} 5 | import { useRef } from 'react' 6 | 7 | {{/if}} 8 | import { View } from './{{titleCase name}}.view' 9 | 10 | export default { title: 'components/{{titleCase name}}' } 11 | 12 | export const Default: StoryFn = (args) => { 13 | {{#if imperativeHandle}} 14 | const handleRef = useRef(null) 15 | {{/if}} 16 | return 17 | } 18 | 19 | Default.args = {} 20 | 21 | Default.argTypes = {} 22 | -------------------------------------------------------------------------------- /src/utils/get-layout.ts: -------------------------------------------------------------------------------- 1 | import { sass } from '@/utils/sass' 2 | 3 | export const ssrLayout = { 4 | mobile: false, 5 | tablet: false, 6 | desktop: true 7 | } 8 | 9 | export function getLayout() { 10 | if (typeof document !== 'undefined') { 11 | const matchTablet = window.matchMedia(`(min-width: ${sass['breakpoint-tablet']}px)`) 12 | const matchDesktop = window.matchMedia(`(min-width: ${sass['breakpoint-desktop']}px)`) 13 | 14 | const desktop = matchDesktop.matches 15 | const tablet = matchTablet.matches && !desktop 16 | const mobile = !tablet && !desktop 17 | 18 | return { 19 | mobile, 20 | tablet, 21 | desktop 22 | } 23 | } 24 | return ssrLayout 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Nav/Nav.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewHandle, ViewProps } from './Nav.view' 3 | 4 | import { useEffect, useRef } from 'react' 5 | 6 | import { CmsService } from '@/services/cms.service' 7 | 8 | import { View } from './Nav.view' 9 | 10 | export default { title: 'components/Nav' } 11 | 12 | export const Default: StoryFn = (args) => { 13 | const handleRef = useRef(null) 14 | useEffect(() => { 15 | handleRef.current?.animateIn() 16 | }, []) 17 | return 18 | } 19 | 20 | Default.args = { 21 | content: CmsService.getPageContent('home').common.nav 22 | } 23 | 24 | Default.argTypes = {} 25 | -------------------------------------------------------------------------------- /src/utils/set-body-classes.ts: -------------------------------------------------------------------------------- 1 | import { ResizeService } from '@/services/resize.service' 2 | 3 | import { browser, device, os } from '@/utils/detect' 4 | 5 | export function setBodyClasses() { 6 | const classes = [ 7 | device.mobile ? 'mobile-device' : '', 8 | device.touch ? 'touch-device' : '', 9 | device.type, 10 | browser.name, 11 | os.name 12 | ].filter(Boolean) 13 | classes.forEach((c) => document.body.classList.add(c.toLowerCase().split(' ').join('-'))) 14 | 15 | // Fix vh problem on mobile 16 | const calculateVh = () => { 17 | document.documentElement.style.setProperty('--vh', `${window.innerHeight / 100}px`) 18 | } 19 | ResizeService.listen(calculateVh) 20 | calculateVh() 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/use-click-away.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | 5 | const useClickAway = (ref: RefObject, onClickAway: () => void) => { 6 | const callbackRef = useRef(onClickAway) 7 | 8 | useEffect(() => { 9 | callbackRef.current = onClickAway 10 | }, [onClickAway]) 11 | 12 | useEffect(() => { 13 | const handler = (event: Event) => { 14 | const el = ref.current 15 | if (el && !el.contains(event.target as Node)) callbackRef.current() 16 | } 17 | document.addEventListener('click', handler) 18 | return () => { 19 | document.removeEventListener('click', handler) 20 | } 21 | }, [ref]) 22 | } 23 | 24 | export default useClickAway 25 | -------------------------------------------------------------------------------- /src/motion/rive/Intro.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentProps, FC } from 'react' 2 | import type { UseRiveOptions, UseRiveParameters } from '@rive-app/react-canvas' 3 | 4 | import { Fit, Layout, useRive } from '@rive-app/react-canvas' 5 | 6 | interface Props extends ComponentProps<'canvas'> { 7 | riveParams?: UseRiveParameters 8 | riveOpts?: Partial 9 | } 10 | 11 | export const Intro: FC = ({ riveParams, riveOpts, ...props }) => { 12 | const { RiveComponent } = useRive( 13 | { 14 | src: require('@/assets/rive/x-intro.riv'), 15 | layout: new Layout({ fit: Fit.Cover }), 16 | autoplay: true, 17 | ...riveParams 18 | }, 19 | riveOpts 20 | ) 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /.storybook/intro/Typography.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | width: 100vw; 5 | display: grid; 6 | text-align: left; 7 | grid-template-columns: 25% 75%; 8 | 9 | .item { 10 | border-bottom: 1px solid rgba($white, 0.4); 11 | padding: px(30) px(15); 12 | font-family: monospace; 13 | 14 | .figma { 15 | font-size: 14px; 16 | font-weight: bold; 17 | } 18 | 19 | .sass { 20 | font-size: 12px; 21 | } 22 | } 23 | 24 | p { 25 | border-bottom: 1px solid rgba($white, 0.4); 26 | padding: px(30) px(15); 27 | word-break: break-all; 28 | } 29 | 30 | .h1 { 31 | @include typography-h1; 32 | } 33 | 34 | .paragraph { 35 | @include typography-paragraph; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/BaseButton/BaseButton.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | import type { ViewProps } from './BaseButton.view' 3 | 4 | import { View } from './BaseButton.view' 5 | 6 | export default { title: 'components/BaseButton' } 7 | 8 | export const Button: StoryFn = (args) => ( 9 | 10 | I'm a button since I don't have an href prop 11 | 12 | ) 13 | 14 | export const Link: StoryFn = (args) => ( 15 | 16 | I'm a link cause I have an href prop! 17 |
18 | Next.js routing navigation 19 |
20 | will happen automatically. 21 |
22 | If the href starts with "/". 23 |
24 | ) 25 | -------------------------------------------------------------------------------- /src/hooks/use-cookie.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | 3 | import { CookieService } from '@/services/cookie.service' 4 | 5 | export const useCookie = (name: string): [value: string | undefined, setValue: (value: string) => void] => { 6 | const [value, setValue] = useState() 7 | 8 | const setStoredValue = useCallback((val: string) => CookieService.set(name, val), [name]) 9 | 10 | useEffect(() => { 11 | setValue(CookieService.get(name)) 12 | const onUpdate = (n: string, val: string | undefined) => n === name && setValue(val) 13 | CookieService.listen(onUpdate) 14 | return () => { 15 | CookieService.dismiss(onUpdate) 16 | } 17 | }, [name]) 18 | 19 | return [value, setStoredValue] 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ScreenNoScript/ScreenNoScript.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { CommonContent } from '@/services/cms.service' 3 | 4 | import { memo } from 'react' 5 | 6 | import { useFeatureFlags } from '@/hooks/use-feature-flags' 7 | 8 | import { View } from './ScreenNoScript.view' 9 | 10 | export interface ControllerProps { 11 | className?: string 12 | content: CommonContent['screenNoScript'] 13 | } 14 | 15 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 16 | export const Controller: FC = memo((props) => { 17 | const { flags } = useFeatureFlags() 18 | return flags.javascriptRequired ? : null 19 | }) 20 | 21 | Controller.displayName = 'ScreenNoScript_Controller' 22 | -------------------------------------------------------------------------------- /.circleci/workflows/pull-requests.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | workflows: 4 | version: 2 5 | pull-requests: 6 | jobs: 7 | - setup: 8 | context: NEXTJS_BOILERPLATE 9 | filters: 10 | branches: 11 | ignore: 12 | - develop 13 | - staging 14 | - main 15 | - linters: 16 | name: linters 17 | requires: 18 | - setup 19 | - secrets-key-detection: 20 | requires: 21 | - setup 22 | - build: 23 | requires: 24 | - linters 25 | - secrets-key-detection 26 | - preview-environment: 27 | context: GITHUB_CREDENTIALS 28 | requires: 29 | - build 30 | env_suffix: '_DEV' 31 | -------------------------------------------------------------------------------- /scripts/templates/slice.ts.hbs: -------------------------------------------------------------------------------- 1 | import type { AppState, Mutators } from './store' 2 | import type { StateCreator } from 'zustand' 3 | 4 | export type {{titleCase name}}SliceState = { 5 | {{camelCase name}}: { 6 | // getters 7 | {{camelCase name}}Enabled: boolean 8 | // setters 9 | set{{titleCase name}}Enabled: ({{camelCase name}}Enabled: boolean) => void 10 | } 11 | } 12 | 13 | export const {{titleCase name}}Slice: StateCreator = (set) => ({ 14 | {{camelCase name}}: { 15 | {{camelCase name}}Enabled: false, 16 | 17 | set{{titleCase name}}Enabled: ({{camelCase name}}Enabled) => { 18 | set((state) => { 19 | state.{{camelCase name}}.{{camelCase name}}Enabled = {{camelCase name}}Enabled 20 | }) 21 | } 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /src/hooks/use-reduced-motion.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | // https://css-tricks.com/introduction-reduced-motion-media-query/ 4 | 5 | export const useReducedMotion = () => { 6 | const [reducedMotion, setReducedMotion] = useState(false) 7 | 8 | useEffect(() => { 9 | const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)') 10 | if (mediaQuery && mediaQuery.matches) setReducedMotion(true) 11 | function onChange(event: MediaQueryListEvent) { 12 | const element = event.target as MediaQueryList 13 | setReducedMotion(element.matches) 14 | } 15 | mediaQuery?.addEventListener('change', onChange) 16 | 17 | return () => { 18 | mediaQuery?.removeEventListener('change', onChange) 19 | } 20 | }, []) 21 | 22 | return reducedMotion 23 | } 24 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'header-max-length': [2, 'always', 72], 5 | 'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']], 6 | 'subject-full-stop': [2, 'never', '.'], 7 | 'type-case': [2, 'always', 'lower-case'], 8 | 'type-enum': [ 9 | 2, 10 | 'always', 11 | [ 12 | 'build', 13 | 'chore', 14 | 'ci', 15 | 'docs', 16 | 'fix', 17 | 'feature', 18 | 'issue', 19 | 'perf', 20 | 'refactor', 21 | 'revert', 22 | 'style', 23 | 'test', 24 | 'pxpush', 25 | 'motion', 26 | 'release', 27 | 'tag', 28 | 'improve' 29 | ] 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react' 2 | 3 | import { LocalStorageService } from '@/services/local-storage.service' 4 | 5 | export const useLocalStorage = (name: string): [value: string | undefined, setValue: (value: string) => boolean] => { 6 | const [value, setValue] = useState() 7 | 8 | const setStoredValue = useCallback((val: string) => LocalStorageService.set(name, val), [name]) 9 | 10 | useEffect(() => { 11 | setValue(LocalStorageService.get(name)) 12 | const onUpdate = (n: string, val: string | undefined) => n === name && setValue(val) 13 | LocalStorageService.listen(onUpdate) 14 | return () => { 15 | LocalStorageService.dismiss(onUpdate) 16 | } 17 | }, [name]) 18 | 19 | return [value, setStoredValue] 20 | } 21 | -------------------------------------------------------------------------------- /src/motion/effects/text/textCounter/textCounter.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | const effect: CustomEffectConfig = { 4 | name: 'textCounter', 5 | effect: (target, config = {}) => { 6 | const element = (target as unknown as HTMLElement[])[0] 7 | const counter = { value: config.start || 0 } 8 | 9 | return gsap.timeline().to(counter, { 10 | value: config.end, 11 | duration: config.duration, 12 | ease: config.ease, 13 | delay: config.delay, 14 | onUpdate() { 15 | if (!element) return 16 | element.textContent = counter.value.toFixed(0) 17 | } 18 | }) 19 | }, 20 | defaults: { 21 | duration: 2, 22 | delay: 0, 23 | start: 0, 24 | end: 0, 25 | ease: 'power3.in' 26 | }, 27 | extendTimeline: true 28 | } 29 | 30 | export default effect 31 | -------------------------------------------------------------------------------- /src/motion/effects/_effects.d.ts: -------------------------------------------------------------------------------- 1 | type CustomEffect = ( 2 | target: Target, 3 | config?: Partial, 4 | position?: gsap.Position 5 | ) => gsap.core.Timeline 6 | 7 | type CustomEffectConfig = { 8 | [EffectName in keyof CustomEffects]: { 9 | name: EffectName 10 | effect: CustomEffects[EffectName] 11 | defaults?: Parameters[1] 12 | extendTimeline?: boolean 13 | } 14 | }[keyof CustomEffects] 15 | 16 | declare namespace gsap { 17 | interface EffectsMap extends CustomEffects {} 18 | 19 | namespace core { 20 | interface Timeline extends CustomEffects {} 21 | } 22 | 23 | // overload the defaults function based on our config 24 | // our config for defaults can be found in `init-gsap.ts` 25 | function defaults(): { ease: EaseFunction; duration: number } 26 | } 27 | -------------------------------------------------------------------------------- /src/services/scroll.service.ts: -------------------------------------------------------------------------------- 1 | type ScrollListener = (e?: Event) => void 2 | 3 | class Service { 4 | listeners: ScrollListener[] = [] 5 | 6 | onScroll = (e: Event) => { 7 | this.listeners.forEach((listener) => listener(e)) 8 | } 9 | 10 | listen = (listener: ScrollListener) => { 11 | if (this.listeners.length === 0) { 12 | window.addEventListener('scroll', this.onScroll, { passive: false }) 13 | } 14 | 15 | if (!this.listeners.includes(listener)) { 16 | this.listeners.push(listener) 17 | } 18 | } 19 | 20 | dismiss = (listener: ScrollListener) => { 21 | this.listeners = this.listeners.filter((l) => l !== listener) 22 | 23 | if (this.listeners.length === 0) { 24 | window.removeEventListener('scroll', this.onScroll) 25 | } 26 | } 27 | } 28 | 29 | export const ScrollService = new Service() 30 | -------------------------------------------------------------------------------- /src/hooks/use-feature-flags.ts: -------------------------------------------------------------------------------- 1 | import type { FeatureFlagId, FeatureFlags } from '@/services/feature-flags.service' 2 | 3 | import { useCallback, useEffect, useState } from 'react' 4 | 5 | import { FeatureFlagService } from '@/services/feature-flags.service' 6 | 7 | export function useFeatureFlags() { 8 | const [flags, setFlags] = useState(FeatureFlagService.getAll()) 9 | 10 | const setFlag = useCallback((name: FeatureFlagId, enabled: boolean) => { 11 | FeatureFlagService.set(name, enabled) 12 | }, []) 13 | 14 | useEffect(() => { 15 | const update = (flgs: FeatureFlags) => setFlags(flgs) 16 | FeatureFlagService.listen(update) 17 | return () => { 18 | FeatureFlagService.dismiss(update) 19 | } 20 | }, []) 21 | 22 | return { 23 | flags, 24 | setFlag, 25 | resetFlags: FeatureFlagService.reset 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/hooks/use-hash-state.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react' 2 | import { useRouter } from 'next/router' 3 | 4 | // Detects if a specific #hash is added to the current route. This is useful for 5 | // opening modals or to trigger specific animations based on the location hash. 6 | 7 | export function useHashState(hashes: string[]): [boolean, () => void, () => void] { 8 | const router = useRouter() 9 | const normalized = useMemo(() => hashes.map((h: string) => h.replace(/#/gu, '')), [hashes]) 10 | const active = useMemo( 11 | () => normalized.some((hash) => router.asPath.includes(`#${hash}`)), 12 | [normalized, router.asPath] 13 | ) 14 | const enable = useCallback(() => router.push({ hash: normalized[0] }), [normalized, router]) 15 | const disable = useCallback(() => router.back(), [router]) 16 | return [active, enable, disable] 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[Feature] TODO_CHANGE_FEATURE_TITLE ' 5 | labels: '' 6 | assignees: iranreyes 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **What does the proposed API look like?** 19 | What kind of methods it will have, how do you think you will use it? 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /src/utils/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Work_Sans } from 'next/font/google' 2 | 3 | // Documentation 4 | // https:nextjs.org/docs/app/building-your-application/optimizing/fonts 5 | 6 | // Google fonts imports 7 | const workSans = Work_Sans({ 8 | subsets: ['latin'], 9 | weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], 10 | display: 'swap', 11 | style: ['normal', 'italic'], 12 | variable: '--font-work-sans' 13 | }) 14 | 15 | // import localFont from 'next/font/local' 16 | 17 | // Local font import example 18 | // const localFontExample = localFont({ 19 | // src: '../assets/fonts/LocalFont/LocalFont-Regular.woff2', 20 | // weight: '400', 21 | // style: 'normal', 22 | // display: 'swap', 23 | // variable: '--font-local' 24 | // }) 25 | 26 | const fonts = [workSans] 27 | export const fontVariables = fonts.map((font) => font.variable).join(' ') 28 | -------------------------------------------------------------------------------- /src/hooks/use-window-size.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from 'react' 2 | 3 | import { ResizeService } from '@/services/resize.service' 4 | 5 | import { detector } from '@/utils/detect' 6 | 7 | interface State { 8 | width: number 9 | height: number 10 | } 11 | 12 | export function useWindowSize() { 13 | const [state, setState] = useReducer((curState: State, newState: State) => ({ ...curState, ...newState }), { 14 | width: detector.window.innerWidth, 15 | height: detector.window.innerHeight 16 | }) 17 | 18 | useEffect(() => { 19 | function update() { 20 | setState({ 21 | width: window.innerWidth, 22 | height: window.innerHeight 23 | }) 24 | } 25 | update() 26 | ResizeService.listen(update) 27 | return () => { 28 | ResizeService.dismiss(update) 29 | } 30 | }, []) 31 | 32 | return state 33 | } 34 | -------------------------------------------------------------------------------- /src/data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "featureFlags": { 3 | "javascriptRequired": { 4 | "label": "Javascript Required", 5 | "enabled": true 6 | }, 7 | "optimizedImages": { 8 | "label": "Optimized Images", 9 | "enabled": false 10 | }, 11 | "dynamicResponsiveness": { 12 | "label": "Dynamic Responsiveness", 13 | "enabled": false, 14 | "hot": true 15 | } 16 | }, 17 | "resizeDebounceTime": 10, 18 | "websiteUrl": "https://localhost:3000", 19 | "dnsPrefetch": { 20 | "development": [], 21 | "production": ["https://www.google-analytics.com", "https://www.googletagmanager.com"], 22 | "test": [] 23 | }, 24 | "analytics": { 25 | "gtmIds": { "prod": "GTM-0000000", "stage": "GTM-0000000", "dev": "GTM-0000000", "local": "" }, 26 | "gtmParams": { "prod": "", "stage": "", "dev": "", "local": "" } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/AppAdmin/AppAdmin.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { memo, useMemo } from 'react' 4 | 5 | import { getRuntimeEnv } from '@/utils/runtime-env' 6 | 7 | import { View } from './AppAdmin.view' 8 | 9 | export interface ControllerProps { 10 | className?: string 11 | } 12 | 13 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 14 | export const Controller: FC = memo((props) => { 15 | const env = useMemo(() => getRuntimeEnv(), []) 16 | 17 | return ( 18 | 25 | ) 26 | }) 27 | 28 | Controller.displayName = 'AppAdmin_Controller' 29 | -------------------------------------------------------------------------------- /src/services/orientation.service.ts: -------------------------------------------------------------------------------- 1 | type OrientationListener = (e?: Event) => void 2 | 3 | class Service { 4 | listeners: OrientationListener[] = [] 5 | 6 | onOrientation = (e: Event) => { 7 | this.listeners.forEach((listener) => listener(e)) 8 | } 9 | 10 | listen = (listener: OrientationListener) => { 11 | if (this.listeners.length === 0) { 12 | window.addEventListener('orientationchange', this.onOrientation) 13 | } 14 | 15 | if (!this.listeners.includes(listener)) { 16 | this.listeners.push(listener) 17 | } 18 | } 19 | 20 | dismiss = (listener: OrientationListener) => { 21 | this.listeners = this.listeners.filter((l) => l !== listener) 22 | 23 | if (this.listeners.length === 0) { 24 | window.removeEventListener('orientationchange', this.onOrientation) 25 | } 26 | } 27 | } 28 | 29 | export const OrientationService = new Service() 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "preserve", 4 | "strict": true, 5 | "target": "es2022", 6 | "module": "esnext", 7 | "noEmit": true, 8 | "allowJs": true, 9 | "incremental": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "isolatedModules": true, 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "lib": ["dom", "dom.iterable", "esnext"], 18 | "paths": { "@/*": ["src/*"], "#/*": [".generated/*"] }, 19 | "plugins": [{ "name": "next" }] 20 | }, 21 | "include": [ 22 | "**/*.ts", 23 | "**/*.tsx", 24 | "types.d.ts", 25 | "next-env.d.ts", 26 | ".storybook/**/*.ts", 27 | ".storybook/**/*.tsx", 28 | ".next/types/**/*.ts" 29 | ], 30 | "exclude": ["node_modules", "scripts/templates"] 31 | } 32 | -------------------------------------------------------------------------------- /scripts/dev-server/index.js: -------------------------------------------------------------------------------- 1 | const { createServer } = require('https') 2 | const { parse } = require('url') 3 | const fs = require('fs') 4 | const path = require('path') 5 | const next = require('next') 6 | 7 | const port = parseInt(process.env.PORT || '3000', 10) 8 | const dev = process.env.NODE_ENV !== 'production' 9 | const app = next({ dev }) 10 | const handle = app.getRequestHandler() 11 | 12 | app.prepare().then(() => { 13 | const cert = fs.readFileSync(path.join(__dirname, '/certificates/localhost.crt')) 14 | const key = fs.readFileSync(path.join(__dirname, '/certificates/localhost.key')) 15 | 16 | createServer({ cert, key }, (req, res) => { 17 | const parsedUrl = parse(req.url, true) 18 | handle(req, res, parsedUrl) 19 | }).listen(port, () => { 20 | console.log(`> Ready on https://localhost:${port}`) 21 | if (dev) require('opener')(`https://localhost:${port}`) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/hooks/use-transition-presence.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | import { store } from '@/store/store' 4 | 5 | import { useBeforeUnmount } from '@/hooks/use-before-unmount' 6 | 7 | export function useTransitionPresence(animations?: { 8 | animateIn?: () => gsap.core.Animation 9 | animateOut?: () => gsap.core.Animation 10 | }) { 11 | const introComplete = store((state) => state.animations.introComplete) 12 | 13 | useEffect(() => { 14 | if (!animations || !introComplete) return 15 | const anim = animations.animateIn?.() 16 | return () => { 17 | anim?.kill() 18 | } 19 | }, [animations, introComplete]) 20 | 21 | useBeforeUnmount(async (abortSignal) => { 22 | if (!animations?.animateOut) return 23 | const anim = animations.animateOut() 24 | abortSignal.addEventListener('abort', () => { 25 | anim.kill() 26 | }) 27 | return anim 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/services/visibility.service.ts: -------------------------------------------------------------------------------- 1 | type VisibilityListener = ((e: Event) => void) | ((e?: Event) => void) 2 | 3 | class Service { 4 | listeners: VisibilityListener[] = [] 5 | 6 | onVisibility = (e: Event) => { 7 | this.listeners.forEach((listener) => listener(e)) 8 | } 9 | 10 | listen = (listener: VisibilityListener) => { 11 | if (this.listeners.length === 0) { 12 | document.addEventListener('visibilitychange', this.onVisibility) 13 | } 14 | 15 | if (!this.listeners.includes(listener)) { 16 | this.listeners.push(listener) 17 | } 18 | } 19 | 20 | dismiss = (listener: VisibilityListener) => { 21 | this.listeners = this.listeners.filter((l) => l !== listener) 22 | 23 | if (this.listeners.length === 0) { 24 | document.removeEventListener('visibilitychange', this.onVisibility) 25 | } 26 | } 27 | } 28 | 29 | export const VisibilityService = new Service() 30 | -------------------------------------------------------------------------------- /src/components/CookieBanner/CookieBanner.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { CommonContent } from '@/services/cms.service' 3 | 4 | import { memo } from 'react' 5 | 6 | import { store } from '@/store/store' 7 | 8 | import { View } from './CookieBanner.view' 9 | 10 | export interface ControllerProps { 11 | className?: string 12 | content: CommonContent['cookieBanner'] 13 | } 14 | 15 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 16 | export const Controller: FC = memo((props) => { 17 | const cookieConsent = store((state) => state.consent.cookieConsent) 18 | const setCookieConsent = store((state) => state.consent.setCookieConsent) 19 | return !cookieConsent ? : null 20 | }) 21 | 22 | Controller.displayName = 'CookieBanner_Controller' 23 | -------------------------------------------------------------------------------- /src/components/PageUnsupported/PageUnsupported.view.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { ControllerProps } from './PageUnsupported.controller' 3 | 4 | import classNames from 'classnames' 5 | 6 | import css from './PageUnsupported.module.scss' 7 | 8 | import { copy } from '@/utils/copy' 9 | 10 | import { useRefs } from '@/hooks/use-refs' 11 | 12 | export interface ViewProps extends ControllerProps {} 13 | 14 | export type ViewRefs = { 15 | root: HTMLDivElement 16 | } 17 | 18 | // View (pure and testable component, receives props exclusively from the controller) 19 | export const View: FC = ({ content }) => { 20 | const refs = useRefs() 21 | 22 | return ( 23 |
24 |

25 |

26 | ) 27 | } 28 | 29 | View.displayName = 'PageUnsupported_View' 30 | -------------------------------------------------------------------------------- /src/utils/runtime-env.ts: -------------------------------------------------------------------------------- 1 | export type RuntimeEnv = 'local' | 'dev' | 'stage' | 'prod' 2 | 3 | let result: RuntimeEnv | undefined = typeof window === 'undefined' ? 'local' : undefined 4 | 5 | export function getRuntimeEnv(): RuntimeEnv { 6 | if (result) return result 7 | if (process.env.STORYBOOK) return 'local' 8 | 9 | const prefix = window.location.hostname.split('.')[0] 10 | 11 | if (/^(localhost|\d)/iu.test(prefix)) { 12 | result = 'local' 13 | } else if (/^(uat|prd|prod|www|or-the-project-subdomain)/iu.test(prefix)) { 14 | result = 'prod' 15 | } else if (/^(stag|stg)/iu.test(prefix)) { 16 | result = 'stage' 17 | } else if (/^(dev)/iu.test(prefix)) { 18 | result = 'dev' 19 | } else { 20 | result = prefix as RuntimeEnv 21 | } 22 | 23 | return result 24 | } 25 | 26 | export function isDevEnv(): boolean { 27 | const env = getRuntimeEnv() 28 | return /^(preview|local|dev|stag|stg|\d)/iu.test(env) 29 | } 30 | -------------------------------------------------------------------------------- /src/services/cookie.service.ts: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie' 2 | 3 | type CookieListener = (name: string, value: string | undefined) => void 4 | 5 | class Service { 6 | listeners: CookieListener[] = [] 7 | 8 | set = (name: string, value: string, options?: Cookies.CookieAttributes) => { 9 | Cookies.set(name, value, { expires: 30, ...options }) 10 | this.listeners.forEach((listener) => listener(name, value)) 11 | } 12 | 13 | get = (name: string) => { 14 | return Cookies.get(name) 15 | } 16 | 17 | delete = (name: string) => { 18 | return Cookies.remove(name) 19 | } 20 | 21 | listen = (listener: CookieListener) => { 22 | if (!this.listeners.includes(listener)) { 23 | this.listeners.push(listener) 24 | } 25 | } 26 | 27 | dismiss = (listener: CookieListener) => { 28 | this.listeners = this.listeners.filter((l) => l !== listener) 29 | } 30 | } 31 | 32 | export const CookieService = new Service() 33 | -------------------------------------------------------------------------------- /src/hooks/use-layout.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { ResizeService } from '@/services/resize.service' 4 | 5 | import { getLayout, ssrLayout } from '@/utils/get-layout' 6 | 7 | /** 8 | * Layout hook 9 | * Set layout on window resize 10 | * @returns {object} Current layout object 11 | * 12 | * Example: 13 | * import useLayout from '@/hooks/use-layout'; 14 | * const layout = useLayout(); 15 | */ 16 | export function useLayout() { 17 | const [currentLayout, setCurrentLayout] = useState(ssrLayout) 18 | 19 | useEffect(() => { 20 | function handleResize() { 21 | const layout = getLayout() 22 | if (JSON.stringify(layout) !== JSON.stringify(currentLayout)) setCurrentLayout(layout) 23 | } 24 | ResizeService.listen(handleResize) 25 | handleResize() 26 | return () => { 27 | ResizeService.dismiss(handleResize) 28 | } 29 | }, [currentLayout]) 30 | 31 | return currentLayout 32 | } 33 | -------------------------------------------------------------------------------- /.storybook/intro/Colors.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { StoryFn } from '@storybook/react' 3 | 4 | import React from 'react' 5 | 6 | import css from './Colors.module.scss' 7 | 8 | import { sass } from '../../src/utils/sass' 9 | 10 | export default { title: 'intro/Colors' } 11 | 12 | const Colors: FC = () => { 13 | return ( 14 |
15 | {Object.entries(sass) 16 | .filter(([, value]) => value.startsWith('#')) 17 | .map(([key, value]) => ( 18 |
19 |
20 |
21 | ${key} ({value}) 22 |
23 |
24 | ))} 25 |
26 | ) 27 | } 28 | 29 | export const Default: StoryFn = (args) => 30 | 31 | Default.args = {} 32 | -------------------------------------------------------------------------------- /.storybook/intro/Typography.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { StoryFn } from '@storybook/react' 3 | 4 | import React, { Fragment } from 'react' 5 | 6 | import css from './Typography.module.scss' 7 | 8 | export default { title: 'intro/Typography' } 9 | 10 | const Typographies: FC<{ chars: string }> = ({ chars }) => { 11 | return ( 12 |
13 | {['h1', 'paragraph'].map((t) => ( 14 | 15 |
16 |
{t}
17 |
@include typography-{t};
18 |
19 |

{chars}

20 |
21 | ))} 22 |
23 | ) 24 | } 25 | 26 | export const Default: StoryFn<{ chars: string }> = (args) => 27 | 28 | Default.args = { 29 | chars: 'The relentless\npursuit of better' 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/children-are-equal.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement, ReactFragment } from 'react' 2 | 3 | export function childrenAreEqual( 4 | previousChildren: ReactElement | ReactFragment | null, 5 | nextChildren: ReactElement | ReactFragment | null 6 | ): boolean { 7 | if (previousChildren === nextChildren) { 8 | return true 9 | } 10 | 11 | // React reconciler will create a new instance when children type changes 12 | if ( 13 | (previousChildren !== null && 'type' in previousChildren && previousChildren.type) !== 14 | (nextChildren !== null && 'type' in nextChildren && nextChildren.type) 15 | ) { 16 | return false 17 | } 18 | 19 | // React reconciler will create a new instance when children key changes 20 | if ( 21 | (previousChildren !== null && 'key' in previousChildren && previousChildren.key) !== 22 | (nextChildren !== null && 'key' in nextChildren && nextChildren.key) 23 | ) { 24 | return false 25 | } 26 | 27 | return true 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/use-orientation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { OrientationService } from '@/services/orientation.service' 4 | import { ResizeService } from '@/services/resize.service' 5 | 6 | import { device } from '@/utils/detect' 7 | 8 | export function useOrientation(includeDesktop = false) { 9 | const [current, setCurrent] = useState({ portrait: true, landscape: false }) // ssr 10 | 11 | useEffect(() => { 12 | const update = () => { 13 | setCurrent({ landscape: device.landscape, portrait: !device.landscape }) 14 | } 15 | 16 | if (device.mobile || includeDesktop) { 17 | update() 18 | if (includeDesktop) { 19 | ResizeService.listen(update) 20 | } else { 21 | OrientationService.listen(update) 22 | } 23 | } 24 | 25 | return () => { 26 | ResizeService.dismiss(update) 27 | OrientationService.dismiss(update) 28 | } 29 | }, [includeDesktop]) 30 | 31 | return current 32 | } 33 | -------------------------------------------------------------------------------- /src/components/BaseImage/BaseImage.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { ImgHTMLAttributes } from 'react' 2 | import type { StaticImageData } from 'next/image' 3 | import type { OptmizedImageEdits } from '@/utils/get-optimized-image-url' 4 | 5 | import { forwardRef, memo } from 'react' 6 | 7 | import { View } from './BaseImage.view' 8 | 9 | export interface ControllerProps extends ImgHTMLAttributes { 10 | src?: string 11 | data?: StaticImageData 12 | options?: OptmizedImageEdits 13 | srcWidths?: number[] 14 | allowRetina?: boolean 15 | fetchpriority?: 'high' | 'low' | 'auto' 16 | skipOptimization?: boolean 17 | onLoad?: () => void 18 | } 19 | 20 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 21 | export const Controller = memo( 22 | forwardRef((props, ref) => { 23 | return 24 | }) 25 | ) 26 | 27 | Controller.displayName = 'BaseImage_Controller' 28 | -------------------------------------------------------------------------------- /.circleci/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | #### Synchronizing with AWS #### 5 | cd out 6 | 7 | # Setup 8 | if [ "$CI_ENV" == "development" ]; then 9 | export DELETE_OLD_FILES=--delete 10 | fi 11 | 12 | # Sync bundles with strong cache 13 | aws s3 sync ./common/favicons s3://${S3_ORIGIN_BUCKET}/common/favicons --metadata-directive 'REPLACE' --cache-control max-age=31536000,public ${DELETE_OLD_FILES} 14 | aws s3 sync ./_next s3://${S3_ORIGIN_BUCKET}/_next --metadata-directive 'REPLACE' --cache-control max-age=31536000,public ${DELETE_OLD_FILES} 15 | aws s3 sync ./common/assets s3://${S3_ORIGIN_BUCKET}/common/assets --metadata-directive 'REPLACE' --cache-control max-age=31536000,public ${DELETE_OLD_FILES} 16 | 17 | # Sync htmls and others with no cache 18 | aws s3 sync ./ s3://${S3_ORIGIN_BUCKET} --exclude "common/favicons/*" --exclude "_next/*" --exclude "common/assets/*" ${DELETE_OLD_FILES} 19 | 20 | # at this point you should invaldiate cache 21 | # this is now in cache-invalidate.sh 22 | -------------------------------------------------------------------------------- /src/components/ScreenNoScript/ScreenNoScript.view.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { ControllerProps } from './ScreenNoScript.controller' 3 | 4 | import classNames from 'classnames' 5 | 6 | import css from './ScreenNoScript.module.scss' 7 | 8 | import { copy } from '@/utils/copy' 9 | 10 | export interface ViewProps extends ControllerProps {} 11 | 12 | // View (pure and testable component, receives props exclusively from the controller) 13 | export const View: FC = ({ className, content }) => { 14 | const Component = process.env.STORYBOOK ? 'div' : 'noscript' 15 | 16 | return ( 17 | 18 |
19 |

20 |

21 |

22 |
23 | ) 24 | } 25 | 26 | View.displayName = 'ScreenNoScript_View' 27 | -------------------------------------------------------------------------------- /src/motion/effects/timeline/timelineFromTo/timelineFromTo.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | import { effectTimeline } from '@/motion/core/effect-timeline' 4 | 5 | const effect: CustomEffectConfig = { 6 | name: 'timelineFromTo', 7 | effect: (target, config = {}) => { 8 | const tl = (target as unknown as (gsap.core.Timeline | (() => gsap.core.Timeline))[])[0] 9 | 10 | return effectTimeline(config.duration!, config.reversed!, () => { 11 | const timeline = typeof tl === 'function' ? tl() : tl 12 | return gsap.timeline({ paused: true }).add( 13 | timeline.tweenFromTo(timeline.duration() * (config.from ?? 0), timeline.duration() * (config.to ?? 1), { 14 | duration: config.duration, 15 | ease: config.ease 16 | }) 17 | ) 18 | }) 19 | }, 20 | defaults: { 21 | ease: 'none', 22 | from: 0, 23 | to: 1, 24 | duration: +(gsap.defaults().duration || 1) 25 | }, 26 | extendTimeline: true 27 | } 28 | 29 | export default effect 30 | -------------------------------------------------------------------------------- /src/components/ScreenIntro/ScreenIntro.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { memo, useCallback } from 'react' 4 | import dynamic from 'next/dynamic' 5 | 6 | import { store } from '@/store/store' 7 | 8 | const View = dynamic(() => import('./ScreenIntro.view').then((m) => m.View), { ssr: false }) 9 | 10 | export interface ControllerProps { 11 | className?: string 12 | } 13 | 14 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 15 | export const Controller: FC = memo((props) => { 16 | const introComplete = store(({ animations }) => animations.introComplete) 17 | const setIntroComplete = store(({ animations }) => animations.setIntroComplete) 18 | 19 | const handleComplete = useCallback(() => { 20 | setIntroComplete(true) 21 | }, [setIntroComplete]) 22 | 23 | return introComplete ? null : 24 | }) 25 | 26 | Controller.displayName = 'ScreenIntro_Controller' 27 | -------------------------------------------------------------------------------- /src/utils/scroll-page.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | import { noop } from './basic-functions' 4 | 5 | interface ScrollProps { 6 | x: number 7 | y: number 8 | duration: number 9 | ease: string 10 | } 11 | 12 | const defaultProps: ScrollProps = { 13 | x: 0, 14 | y: 0, 15 | duration: 0, // in seconds 16 | ease: 'none' 17 | } 18 | 19 | let timeoutId: NodeJS.Timeout 20 | 21 | /** 22 | * Scroll page to a specific position 23 | * 24 | * @param {object} [props={}] - Scroll options. Refer to 'defaultProps' object 25 | * @param {function} [onComplete=noop] - On complete trigger function 26 | */ 27 | export function scrollPage(props: Partial = {}, onComplete = noop) { 28 | const combinedProps = { ...defaultProps, ...props } 29 | const { x, y, duration, ease } = combinedProps 30 | 31 | if (timeoutId) clearTimeout(timeoutId) 32 | timeoutId = setTimeout(onComplete, duration * 1000) 33 | 34 | gsap.to(window, { duration, scrollTo: { x, y, autoKill: false }, ease }) 35 | } 36 | -------------------------------------------------------------------------------- /src/motion/effects/text/textCounter/textCounter.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { easeNames } from '@/motion/eases/eases' 7 | 8 | import textCounter from './textCounter' 9 | 10 | export default { title: 'motion/Effects/text/textCounter' } 11 | 12 | gsap.registerEffect(textCounter) 13 | 14 | export const Default: StoryFn = (args) => { 15 | const ref = useRef(null) 16 | 17 | useEffect(() => { 18 | const timeline = gsap.timeline() 19 | if (ref.current) timeline.textCounter(ref.current!, args, 0.4) 20 | return () => { 21 | timeline.kill() 22 | } 23 | }, [args]) 24 | 25 | return
26 | } 27 | 28 | Default.args = { 29 | start: 0, 30 | end: 1000, 31 | duration: 2, 32 | ease: 'expo.inOut' 33 | } 34 | 35 | Default.argTypes = { 36 | ease: { options: easeNames, control: { type: 'select' } } 37 | } 38 | -------------------------------------------------------------------------------- /src/motion/core/init.ts: -------------------------------------------------------------------------------- 1 | import { RuntimeLoader } from '@rive-app/react-canvas' 2 | import { gsap } from 'gsap' 3 | import CustomEase from 'gsap/dist/CustomEase' 4 | import ScrollToPlugin from 'gsap/dist/ScrollToPlugin' 5 | 6 | import { customEases, favouriteEases } from '../eases/eases' 7 | 8 | export const riveWASMResource = require('@rive-app/canvas/rive.wasm') 9 | 10 | export function initRive() { 11 | if (typeof window === 'undefined') return 12 | RuntimeLoader.setWasmUrl(riveWASMResource) 13 | } 14 | 15 | export function initGsap() { 16 | if (typeof window === 'undefined') return 17 | 18 | gsap.registerPlugin(CustomEase, ScrollToPlugin) 19 | 20 | gsap.defaults({ ease: 'none', duration: 1 }) 21 | 22 | Object.values(favouriteEases).forEach((ease) => { 23 | CustomEase.create(ease.name, ease.ease) 24 | }) 25 | 26 | Object.values(customEases).forEach((ease) => { 27 | CustomEase.create(ease.name, ease.ease) 28 | }) 29 | 30 | gsap.registerEffect(require('@/motion/effects/fade/fadeIn/fadeIn').default) 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/use-refs.ts: -------------------------------------------------------------------------------- 1 | import type { ForwardedRef, MutableRefObject, RefObject } from 'react' 2 | 3 | import { useMemo, useRef } from 'react' 4 | 5 | type UnknownMap = { [key: string | symbol]: unknown } 6 | type InitialRefs = { 7 | [key in keyof T]: RefObject | MutableRefObject | ForwardedRef 8 | } 9 | type ResultRefs = { [key in keyof T]: MutableRefObject } 10 | 11 | export function useRefs(initialTarget?: Partial>): ResultRefs { 12 | const proxyTarget = useRef>>((initialTarget ?? {}) as ResultRefs) 13 | 14 | return useMemo( 15 | () => 16 | new Proxy(proxyTarget.current, { 17 | get(target, prop): unknown { 18 | const p = prop as keyof T 19 | if (target[p]) return target[p] 20 | target[p] = { current: undefined } as MutableRefObject 21 | return target[p] 22 | } 23 | }) as ResultRefs, 24 | [] 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[Bug] TODO_CHANGE_BUG_TITLE' 5 | labels: bug 6 | assignees: iranreyes 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | 12 | 13 | ## To Reproduce 14 | 15 | 24 | 25 | ## Screenshots 26 | 27 | 28 | 29 | ## Expected behaviour 30 | 31 | 32 | 33 | ## Environment 34 | 35 | Run the below command and paste the result here: 36 | 37 | ``` 38 | npx envinfo --system --npmPackages react* --binaries --npmGlobalPackages react* --browsers 39 | ``` 40 | 41 | ## Additional context 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/motion/core/effect-timeline.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | export const effectTimeline = ( 4 | duration: gsap.TweenValue, 5 | reversed: boolean, 6 | timelineFactory: () => gsap.core.Timeline 7 | ) => { 8 | let timeline: gsap.core.Timeline 9 | 10 | const helper = { progress: reversed ? 1 : 0, completed: false } 11 | 12 | return gsap 13 | .timeline({ 14 | onStart: () => { 15 | timeline = timelineFactory() 16 | }, 17 | onUpdate: () => { 18 | if (helper.completed) { 19 | // if onUpdate is called after the timeline is finished 20 | // it means the timeline is playing backwards for some reason. 21 | // This is often due scrolltrigger scrubbing. 22 | helper.completed = false 23 | timeline = timelineFactory() 24 | } 25 | timeline?.progress(helper.progress) 26 | }, 27 | onComplete: () => { 28 | helper.completed = true 29 | } 30 | }) 31 | .to(helper, { progress: reversed ? 0 : 1, duration, ease: 'none' }) 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.view.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { ControllerProps } from './Footer.controller' 3 | 4 | import classNames from 'classnames' 5 | 6 | import css from './Footer.module.scss' 7 | 8 | import { useRefs } from '@/hooks/use-refs' 9 | 10 | import { BaseButton } from '@/components/BaseButton' 11 | 12 | export interface ViewProps extends ControllerProps {} 13 | 14 | export type ViewRefs = { 15 | root: HTMLDivElement 16 | } 17 | 18 | // View (pure and testable component, receives props exclusively from the controller) 19 | export const View: FC = ({ className, content }) => { 20 | const refs = useRefs() 21 | 22 | return ( 23 |
24 |
    25 | {content.routes.map(({ path, title }) => ( 26 |
  • 27 | {title} 28 |
  • 29 | ))} 30 |
31 |
32 | ) 33 | } 34 | 35 | View.displayName = 'Footer_View' 36 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | const escape = require('shell-quote').quote 2 | const isWin = process.platform === 'win32' 3 | 4 | module.exports = { 5 | '**/*.{js,jsx,ts,tsx}': (filenames) => { 6 | const escapedFileNames = filenames.map((filename) => `"${isWin ? filename : escape([filename])}"`).join(' ') 7 | return [ 8 | `prettier --with-node-modules --ignore-path .prettierignore --write ${escapedFileNames}`, 9 | `next lint --ignore-path .eslintignore --max-warnings=0 --fix --file ${filenames 10 | .map((filename) => `"${isWin ? filename : escape([filename])}"`) 11 | .join(' --file ')}`, 12 | `git add ${escapedFileNames}` 13 | ] 14 | }, 15 | '**/*.ts?(x)': () => 'tsc -p tsconfig.json --noEmit', 16 | '**/*.{json,md,mdx,css,html,yml,yaml,scss}': (filenames) => { 17 | const escapedFileNames = filenames.map((filename) => `"${isWin ? filename : escape([filename])}"`).join(' ') 18 | return [ 19 | `prettier --with-node-modules --ignore-path .prettierignore --write ${escapedFileNames}`, 20 | `git add ${escapedFileNames}` 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/services/lock-body-scroll.service.ts: -------------------------------------------------------------------------------- 1 | import { getScrollTop, noop } from '@/utils/basic-functions' 2 | import { device } from '@/utils/detect' 3 | import { scrollPage } from '@/utils/scroll-page' 4 | 5 | /** 6 | * Lock and unlock body scroll with page position restoration 7 | */ 8 | class Service { 9 | scrollPosY = 0 10 | isLocked = false 11 | 12 | lock = device.browser 13 | ? () => { 14 | this.scrollPosY = getScrollTop() 15 | document.body.style.position = 'fixed' 16 | document.body.style.overflowY = 'scroll' 17 | document.body.style.marginTop = `-${this.scrollPosY}px` 18 | this.isLocked = true 19 | } 20 | : noop 21 | 22 | unlock = device.browser 23 | ? (skipPositionRestore = false) => { 24 | document.body.style.position = '' 25 | document.body.style.overflowY = '' 26 | document.body.style.marginTop = '' 27 | if (!skipPositionRestore) scrollPage({ y: this.scrollPosY, duration: 0 }) 28 | this.isLocked = false 29 | } 30 | : noop 31 | } 32 | 33 | export const LockBodyScrollService = new Service() 34 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByWordsOut/textRiseByWordsOut.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | import { effectTimeline } from '@/motion/core/effect-timeline' 4 | import { SafeSplitText } from '@/motion/core/safe-split-text' 5 | 6 | const effect: CustomEffectConfig = { 7 | name: 'textRiseByWordsOut', 8 | effect: (target, config = {}) => { 9 | return effectTimeline(config.duration!, config.reversed!, () => { 10 | const element = (target as unknown as HTMLElement[])[0] 11 | const split = new SafeSplitText(element, { type: 'lines,words' }) 12 | return gsap // 13 | .timeline({ paused: true }) 14 | .set(split.lines, { overflow: 'hidden' }) 15 | .to(split.words, { 16 | yPercent: 105, 17 | duration: config.wordDuration, 18 | stagger: config.wordOffset, 19 | ease: config.ease 20 | }) 21 | }) 22 | }, 23 | defaults: { 24 | ease: 'expo.in', 25 | duration: +(gsap.defaults().duration || 1), 26 | wordDuration: 1, 27 | wordOffset: 0.1 28 | }, 29 | extendTimeline: true 30 | } 31 | 32 | export default effect 33 | -------------------------------------------------------------------------------- /src/store/consent.slice.ts: -------------------------------------------------------------------------------- 1 | import type { AppState, Mutators } from './store' 2 | import type { StateCreator } from 'zustand' 3 | 4 | import { CookieService } from '@/services/cookie.service' 5 | 6 | export type CookieConsent = { 7 | necessary: boolean 8 | persistent: boolean 9 | preference: boolean 10 | statistics: boolean 11 | firstParty: boolean 12 | thirdParty: boolean 13 | marketing: boolean 14 | session: boolean 15 | } 16 | 17 | export type ConsentSliceState = { 18 | consent: { 19 | // getters 20 | cookieConsent: CookieConsent | null 21 | // setters 22 | setCookieConsent: (cookieConsent: CookieConsent) => void 23 | } 24 | } 25 | 26 | export const ConsentSlice: StateCreator = (set) => ({ 27 | consent: { 28 | cookieConsent: JSON.parse(CookieService.get('cookieConsent') || 'null'), 29 | 30 | setCookieConsent: (cookieConsent) => { 31 | set((state) => { 32 | state.consent.cookieConsent = cookieConsent 33 | CookieService.set('cookieConsent', JSON.stringify(cookieConsent)) 34 | }) 35 | } 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import type { AnimationsSliceState } from './animations.slice' 2 | import type { ConsentSliceState } from './consent.slice' 3 | import type { NavigationSliceState } from './navigation.slice' 4 | 5 | import { create } from 'zustand' 6 | import { devtools, subscribeWithSelector } from 'zustand/middleware' 7 | import { immer } from 'zustand/middleware/immer' 8 | 9 | import { AnimationsSlice } from './animations.slice' 10 | import { ConsentSlice } from './consent.slice' 11 | import { NavigationSlice } from './navigation.slice' 12 | 13 | export type Mutators = [['zustand/devtools', never], ['zustand/subscribeWithSelector', never], ['zustand/immer', never]] 14 | 15 | export type AppState = AnimationsSliceState & ConsentSliceState & NavigationSliceState 16 | 17 | export const store = create()( 18 | devtools( 19 | subscribeWithSelector( 20 | immer((...props) => ({ 21 | ...AnimationsSlice(...props), 22 | ...ConsentSlice(...props), 23 | ...NavigationSlice(...props) 24 | })) 25 | ) 26 | ) 27 | ) 28 | 29 | export const storeState = () => store.getState() 30 | -------------------------------------------------------------------------------- /src/components/ScreenIntro/ScreenIntro.view.tsx: -------------------------------------------------------------------------------- 1 | import type { ControllerProps } from './ScreenIntro.controller' 2 | 3 | import { type FC, useState } from 'react' 4 | import classNames from 'classnames' 5 | 6 | import css from './ScreenIntro.module.scss' 7 | 8 | import { useRefs } from '@/hooks/use-refs' 9 | 10 | import { Intro } from '@/motion/rive/Intro' 11 | 12 | export interface ViewProps extends ControllerProps { 13 | onComplete?: () => void 14 | } 15 | 16 | export type ViewRefs = { 17 | root: HTMLDivElement 18 | } 19 | 20 | // View (pure and testable component, receives props exclusively from the controller) 21 | export const View: FC = ({ className, onComplete }) => { 22 | const refs = useRefs() 23 | 24 | const [loaded, setLoaded] = useState(false) 25 | 26 | const handleLoad = () => setLoaded(true) 27 | 28 | return ( 29 |
30 | 31 |
32 | ) 33 | } 34 | 35 | View.displayName = 'ScreenIntro_View' 36 | -------------------------------------------------------------------------------- /src/hooks/use-low-power-mode.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { VisibilityService } from '@/services/visibility.service' 4 | 5 | import { os } from '@/utils/detect' 6 | import { getLowPowerMode } from '@/utils/detect-low-power-mode' 7 | 8 | let cachedResult = false 9 | 10 | export const useLowPowerMode = () => { 11 | const [lowPower, setLowPower] = useState(cachedResult) 12 | 13 | useEffect(() => { 14 | let timeout: NodeJS.Timeout 15 | 16 | const update = () => { 17 | getLowPowerMode() 18 | .then((isLowPower) => { 19 | setLowPower(isLowPower) 20 | 21 | clearTimeout(timeout) 22 | timeout = setTimeout(() => { 23 | update() 24 | }, 1000 * 5) // Check every 5 seconds 25 | }) 26 | .catch(console.log) 27 | } 28 | 29 | if (os.ios) { 30 | update() 31 | VisibilityService.listen(update) 32 | } 33 | 34 | return () => { 35 | clearTimeout(timeout) 36 | VisibilityService.dismiss(update) 37 | } 38 | }, []) 39 | 40 | cachedResult = lowPower 41 | return cachedResult 42 | } 43 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByCharsOut/textRiseByCharsOut.ts: -------------------------------------------------------------------------------- 1 | import { gsap } from 'gsap' 2 | 3 | import { effectTimeline } from '@/motion/core/effect-timeline' 4 | import { SafeSplitText } from '@/motion/core/safe-split-text' 5 | 6 | const effect: CustomEffectConfig = { 7 | name: 'textRiseByCharsOut', 8 | effect: (target, config = {}) => { 9 | return effectTimeline(config.duration!, config.reversed!, () => { 10 | const element = (target as unknown as HTMLElement[])[0] 11 | const split = new SafeSplitText(element, { type: 'words,chars' }) 12 | return gsap 13 | .timeline({ paused: true }) 14 | .set(split.words, { overflow: 'hidden', display: 'inline-flex' }) 15 | .to(split.chars, { 16 | yPercent: -105, 17 | duration: config.charDuration, 18 | stagger: config.charOffset, 19 | ease: config.ease 20 | }) 21 | }) 22 | }, 23 | defaults: { 24 | ease: 'expo.in', 25 | duration: +(gsap.defaults().duration || 1), 26 | charDuration: 1, 27 | charOffset: 0.01 28 | }, 29 | extendTimeline: true 30 | } 31 | 32 | export default effect 33 | -------------------------------------------------------------------------------- /src/services/raf.service.ts: -------------------------------------------------------------------------------- 1 | type RequestAnimationFrameListener = ((delta?: number) => void) | ((delta: number) => void) 2 | 3 | class Service { 4 | listeners: RequestAnimationFrameListener[] = [] 5 | frameId = 0 6 | elapsed = 0 7 | 8 | onFrame = () => { 9 | const now = Date.now() 10 | const delta = now - this.elapsed 11 | this.elapsed = now 12 | this.listeners.forEach((listener) => listener(delta)) 13 | this.frameId = requestAnimationFrame(this.onFrame) 14 | } 15 | 16 | listen = (listener: RequestAnimationFrameListener) => { 17 | if (!this.listeners.includes(listener)) { 18 | this.listeners.push(listener) 19 | } 20 | 21 | if (!this.frameId) { 22 | this.elapsed = Date.now() 23 | this.frameId = requestAnimationFrame(this.onFrame) 24 | } 25 | } 26 | 27 | dismiss = (listener: RequestAnimationFrameListener) => { 28 | this.listeners = this.listeners.filter((l) => l !== listener) 29 | 30 | if (this.listeners.length === 0) { 31 | cancelAnimationFrame(this.frameId) 32 | this.frameId = 0 33 | } 34 | } 35 | } 36 | 37 | export const RafService = new Service() 38 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import type { DocumentContext } from 'next/document' 2 | 3 | import Document, { Head, Html, Main, NextScript } from 'next/document' 4 | 5 | import { copy } from '@/utils/copy' 6 | 7 | class MyDocument extends Document { 8 | static async getInitialProps(ctx: DocumentContext) { 9 | const initialProps = await Document.getInitialProps(ctx) 10 | return { ...initialProps } 11 | } 12 | 13 | render() { 14 | return ( 15 | 16 | 17 | {/* FOUC prevention step 1/2: hide the page immediately. */} 18 |