├── .adviserrc.json ├── .circleci ├── CircleCI.md ├── commands │ ├── main.yml │ └── slack-notification.yml ├── config.yml ├── jobs │ └── config.yml ├── orbs │ └── main.yml ├── scripts │ ├── build.sh │ ├── cache-invalidate.sh │ └── deploy.sh └── workflows │ ├── git-flow-based.yml │ ├── pull-requests.yml │ └── trunk-based.yml ├── .editorconfig ├── .env.development ├── .env.production ├── .envrc ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .husky ├── commit-msg ├── post-checkout ├── post-commit ├── post-merge ├── pre-commit └── pre-push ├── .ls-lint.yml ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .storybook ├── intro │ ├── Colors.module.scss │ ├── Colors.stories.tsx │ ├── Readme.stories.mdx │ ├── Svg.stories.tsx │ ├── Typography.module.scss │ └── Typography.stories.tsx ├── main.ts ├── manager-head.html ├── manager.ts ├── preview-head.html ├── preview.tsx ├── storybook.scss └── webpack.ts ├── .stylelintignore ├── .stylelintrc ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── docs ├── asset-management.md ├── configuring-analytics.md ├── copy-management.md ├── environment-variables.md ├── file-structure-and-organization.md ├── import-aliases.md ├── media-queries.md ├── state-management.md └── template-generator.md ├── lint-staged.config.js ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.js ├── package-lock.json ├── package.json ├── plopfile.js ├── postcss.config.js ├── public ├── common │ ├── assets │ │ ├── images │ │ │ ├── .gitkeep │ │ │ └── share-image.jpg │ │ ├── sounds │ │ │ └── .gitkeep │ │ └── videos │ │ │ └── .gitkeep │ └── favicons │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-144x144.png │ │ ├── favicon-150x150.png │ │ ├── favicon-16x16.png │ │ ├── favicon-192x192.png │ │ ├── favicon-32x32.png │ │ ├── favicon-384x384.png │ │ ├── favicon-512x512.png │ │ ├── favicon.ico │ │ ├── safari-pinned-tab.svg │ │ └── site.webmanifest └── robots.txt ├── scripts ├── dev-server │ ├── certificates │ │ ├── localhost.crt │ │ └── localhost.key │ └── index.js ├── fix-lfs-hooks.ps1 ├── fix-lfs-hooks.sh ├── imports-generate.js ├── imports-watch.js ├── prepare.js ├── public-image-sizes.js └── templates │ ├── api.ts.hbs │ ├── component.controller.tsx.hbs │ ├── component.index.ts.hbs │ ├── component.module.scss.hbs │ ├── component.stories.tsx.hbs │ ├── component.view.tsx.hbs │ ├── page.controller.tsx.hbs │ ├── page.index.ts.hbs │ ├── page.module.scss.hbs │ ├── page.stories.tsx.hbs │ ├── page.view.tsx.hbs │ ├── route.ts.hbs │ └── slice.ts.hbs ├── src ├── assets │ ├── fonts │ │ └── .gitkeep │ ├── images │ │ ├── .gitkeep │ │ ├── experience-logo-500.png │ │ ├── github-icon-64.png │ │ ├── github-icon-64b.png │ │ ├── npm-icon-64b.png │ │ ├── test.png │ │ └── x-logo.png │ └── rive │ │ └── x-intro.riv ├── components │ ├── AppAdmin │ │ ├── AppAdmin.controller.tsx │ │ ├── AppAdmin.module.scss │ │ ├── AppAdmin.stories.tsx │ │ ├── AppAdmin.view.tsx │ │ └── index.ts │ ├── BaseButton │ │ ├── BaseButton.controller.tsx │ │ ├── BaseButton.stories.tsx │ │ ├── BaseButton.view.tsx │ │ └── index.ts │ ├── BaseImage │ │ ├── BaseImage.controller.tsx │ │ ├── BaseImage.module.scss │ │ ├── BaseImage.stories.tsx │ │ ├── BaseImage.view.tsx │ │ └── index.ts │ ├── CookieBanner │ │ ├── CookieBanner.controller.tsx │ │ ├── CookieBanner.module.scss │ │ ├── CookieBanner.stories.tsx │ │ ├── CookieBanner.view.tsx │ │ └── index.ts │ ├── Footer │ │ ├── Footer.controller.tsx │ │ ├── Footer.module.scss │ │ ├── Footer.stories.tsx │ │ ├── Footer.view.tsx │ │ └── index.ts │ ├── Head │ │ ├── MockContentSecurityPolicy.tsx │ │ ├── MockFeaturePolicy.tsx │ │ └── index.tsx │ ├── Layout │ │ ├── Layout.module.scss │ │ └── Layout.tsx │ ├── Nav │ │ ├── Nav.controller.tsx │ │ ├── Nav.module.scss │ │ ├── Nav.stories.tsx │ │ ├── Nav.view.tsx │ │ └── index.ts │ ├── PageAbout │ │ ├── PageAbout.controller.tsx │ │ ├── PageAbout.module.scss │ │ ├── PageAbout.stories.tsx │ │ ├── PageAbout.view.tsx │ │ └── index.ts │ ├── PageHome │ │ ├── PageHome.controller.tsx │ │ ├── PageHome.module.scss │ │ ├── PageHome.stories.tsx │ │ ├── PageHome.view.tsx │ │ └── index.ts │ ├── PageNotFound │ │ ├── PageNotFound.controller.tsx │ │ ├── PageNotFound.module.scss │ │ ├── PageNotFound.stories.tsx │ │ ├── PageNotFound.view.tsx │ │ └── index.ts │ ├── PageUnsupported │ │ ├── PageUnsupported.controller.tsx │ │ ├── PageUnsupported.module.scss │ │ ├── PageUnsupported.stories.tsx │ │ ├── PageUnsupported.view.tsx │ │ └── index.ts │ ├── ScreenIntro │ │ ├── ScreenIntro.controller.tsx │ │ ├── ScreenIntro.module.scss │ │ ├── ScreenIntro.stories.tsx │ │ ├── ScreenIntro.view.tsx │ │ └── index.ts │ ├── ScreenNoScript │ │ ├── ScreenNoScript.controller.tsx │ │ ├── ScreenNoScript.module.scss │ │ ├── ScreenNoScript.stories.tsx │ │ ├── ScreenNoScript.view.tsx │ │ └── index.ts │ └── ScreenRotate │ │ ├── ScreenRotate.controller.tsx │ │ ├── ScreenRotate.module.scss │ │ ├── ScreenRotate.stories.tsx │ │ ├── ScreenRotate.view.tsx │ │ └── index.ts ├── data │ ├── config.json │ ├── content.json │ └── types.ts ├── hooks │ ├── use-before-unmount.ts │ ├── use-click-away.ts │ ├── use-cookie.ts │ ├── use-feature-flags.ts │ ├── use-hash-state.ts │ ├── use-intersection-observer.ts │ ├── use-layout.ts │ ├── use-local-storage.ts │ ├── use-low-power-mode.ts │ ├── use-orientation.ts │ ├── use-reduced-motion.ts │ ├── use-refs.ts │ ├── use-scroll-direction.ts │ ├── use-transition-presence.ts │ ├── use-window-size.ts │ └── use-window-visible.ts ├── motion │ ├── core │ │ ├── effect-timeline.ts │ │ ├── init.ts │ │ └── safe-split-text.ts │ ├── eases │ │ ├── eases.module.scss │ │ ├── eases.stories.tsx │ │ └── eases.ts │ ├── effects │ │ ├── _effects.d.ts │ │ ├── fade │ │ │ ├── fadeFrom │ │ │ │ ├── _.d.ts │ │ │ │ ├── fadeFrom.stories.tsx │ │ │ │ └── fadeFrom.ts │ │ │ ├── fadeIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── fadeIn.stories.tsx │ │ │ │ └── fadeIn.ts │ │ │ └── fadeOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── fadeOut.stories.tsx │ │ │ │ └── fadeOut.ts │ │ ├── mask │ │ │ ├── maskWipeIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── maskWipeIn.stories.tsx │ │ │ │ └── maskWipeIn.ts │ │ │ └── maskWipeOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── maskWipeOut.stories.tsx │ │ │ │ └── maskWipeOut.ts │ │ ├── text │ │ │ ├── textCounter │ │ │ │ ├── _.d.ts │ │ │ │ ├── textCounter.stories.tsx │ │ │ │ └── textCounter.ts │ │ │ ├── textFadeByCharsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByCharsIn.stories.tsx │ │ │ │ └── textFadeByCharsIn.ts │ │ │ ├── textFadeByCharsOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByCharsOut.stories.tsx │ │ │ │ └── textFadeByCharsOut.ts │ │ │ ├── textFadeByLinesIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByLinesIn.stories.tsx │ │ │ │ └── textFadeByLinesIn.ts │ │ │ ├── textFadeByLinesOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByLinesOut.stories.tsx │ │ │ │ └── textFadeByLinesOut.ts │ │ │ ├── textFadeByWordsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByWordsIn.stories.tsx │ │ │ │ └── textFadeByWordsIn.ts │ │ │ ├── textFadeByWordsOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textFadeByWordsOut.stories.tsx │ │ │ │ └── textFadeByWordsOut.ts │ │ │ ├── textRiseByCharsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByCharsIn.stories.tsx │ │ │ │ └── textRiseByCharsIn.ts │ │ │ ├── textRiseByCharsOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByCharsOut.stories.tsx │ │ │ │ └── textRiseByCharsOut.ts │ │ │ ├── textRiseByLinesIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByLinesIn.stories.tsx │ │ │ │ └── textRiseByLinesIn.ts │ │ │ ├── textRiseByLinesOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByLinesOut.stories.tsx │ │ │ │ └── textRiseByLinesOut.ts │ │ │ ├── textRiseByWordsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByWordsIn.stories.tsx │ │ │ │ └── textRiseByWordsIn.ts │ │ │ ├── textRiseByWordsOut │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseByWordsOut.stories.tsx │ │ │ │ └── textRiseByWordsOut.ts │ │ │ ├── textRiseFadeByLinesIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseFadeByLinesIn.stories.tsx │ │ │ │ └── textRiseFadeByLinesIn.ts │ │ │ ├── textRiseFadeByWordsIn │ │ │ │ ├── _.d.ts │ │ │ │ ├── textRiseFadeByWordsIn.stories.tsx │ │ │ │ └── textRiseFadeByWordsIn.ts │ │ │ ├── textScrambleByChars │ │ │ │ ├── _.d.ts │ │ │ │ ├── textScrambleByChars.stories.tsx │ │ │ │ └── textScrambleByChars.ts │ │ │ ├── textScrambleByLines │ │ │ │ ├── _.d.ts │ │ │ │ ├── textScrambleByLines.stories.tsx │ │ │ │ └── textScrambleByLines.ts │ │ │ └── textScrambleByWords │ │ │ │ ├── _.d.ts │ │ │ │ ├── textScrambleByWords.stories.tsx │ │ │ │ └── textScrambleByWords.ts │ │ └── timeline │ │ │ ├── timelineFromTo │ │ │ ├── _.d.ts │ │ │ ├── timelineFromTo.stories.tsx │ │ │ └── timelineFromTo.ts │ │ │ └── timelineTo │ │ │ ├── _.d.ts │ │ │ ├── timelineTo.stories.tsx │ │ │ └── timelineTo.ts │ ├── rive │ │ ├── Intro.stories.tsx │ │ └── Intro.tsx │ └── transition │ │ ├── transition.context.ts │ │ ├── transition.crossflow.tsx │ │ └── transition.presence.tsx ├── pages │ ├── 404.tsx │ ├── 500.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── about.tsx │ ├── api │ │ └── images │ │ │ └── [data].js │ ├── index.tsx │ └── unsupported.tsx ├── services │ ├── analytics.service.ts │ ├── aws-rum.service.ts │ ├── cms.service.ts │ ├── cookie.service.ts │ ├── feature-flags.service.ts │ ├── local-storage.service.ts │ ├── lock-body-scroll.service.ts │ ├── orientation.service.ts │ ├── pointer-move.service.ts │ ├── raf.service.ts │ ├── resize.service.ts │ ├── scroll.service.ts │ └── visibility.service.ts ├── store │ ├── animations.slice.ts │ ├── consent.slice.ts │ ├── navigation.slice.ts │ └── store.ts ├── styles │ ├── export-vars.module.scss │ ├── global.scss │ ├── mixins.scss │ ├── shared.scss │ ├── tailwind.scss │ ├── typography.scss │ └── vars.scss ├── svgs │ └── ExperienceLogo.svg └── utils │ ├── array-ref.ts │ ├── basic-functions.ts │ ├── children-are-equal.ts │ ├── copy.ts │ ├── detect-low-power-mode.ts │ ├── detect.ts │ ├── fonts.ts │ ├── get-id.ts │ ├── get-layout.ts │ ├── get-optimized-image-url.ts │ ├── is-routed-href.ts │ ├── multi-ref.ts │ ├── print.ts │ ├── runtime-env.ts │ ├── sanitizer.ts │ ├── sass.ts │ ├── scroll-page.ts │ ├── set-body-classes.ts │ └── tick.ts ├── tailwind.config.js ├── tsconfig.json └── types.d.ts /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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} -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | !.storybook 2 | scripts/templates/* 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Jira Ticket ID: 7 | 8 | 9 | 10 | **What kind of change does this PR introduce?** (check at least one) 11 | 12 | - [ ] Bugfix (non-breaking change which fixes an issue) 13 | - [ ] Feature (non-breaking change which adds functionality) 14 | - [ ] Code style update 15 | - [ ] Refactor (refactoring or adding test which isn't a fix or add a feature) 16 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 17 | - [ ] Build-related changes 18 | - [ ] Other, please describe: 19 | 20 | **Does this PR introduce a breaking change?** (check one) 21 | 22 | - [ ] Yes 23 | - [ ] No 24 | 25 | **Did you test your solution?** 26 | 27 | - [ ] I lightly tested it in one browser 28 | - [ ] I deeply tested it in several browsers 29 | - [ ] I wrote tests around it (unit tests, integration tests, E2E tests) 30 | 31 | ## Problem Description 32 | 33 | 34 | 35 | ## Solution Description 36 | 37 | 38 | 39 | ## Side Effects, Risks, Impact 40 | 41 | 42 | 43 | - [ ] N/A 44 | 45 | **Aditional comments:** 46 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @gsap:registry=https://npm.greensock.com 2 | //npm.greensock.com/:_authToken=${GSAP_NPM_TOKEN} 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.19.0 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | scripts/templates/* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "endOfLine": "auto", 4 | "printWidth": 120, 5 | "singleQuote": true, 6 | "trailingComma": "none" 7 | } 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.storybook/storybook.scss: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /.storybook/webpack.ts: -------------------------------------------------------------------------------- 1 | // Export a function. Accept the base config as the only param. 2 | import type { Configuration, RuleSetRule } from 'webpack' 3 | 4 | import path from 'node:path' 5 | import webpack from 'webpack' 6 | 7 | const generatedPath = path.resolve(__dirname, '../.generated') 8 | const introPath = path.resolve(__dirname, './intro') 9 | const srcPath = path.resolve(__dirname, '../src') 10 | 11 | export const webpackFinal = async (config: Configuration) => { 12 | if (config.resolve?.alias) { 13 | const alias = config.resolve.alias as { [key: string]: string } 14 | alias['@'] = srcPath 15 | alias['#'] = generatedPath 16 | } 17 | 18 | if (config.module?.rules) { 19 | const rules = config.module.rules as RuleSetRule[] 20 | rules.forEach((rule) => { 21 | if ((rule.test as RegExp)?.test('.scss')) rule.exclude = [introPath] 22 | if ((rule.test as RegExp)?.test('.svg')) rule.exclude = /\.svg$/iu 23 | }) 24 | rules.push( 25 | { 26 | test: /\.scss$/iu, 27 | include: introPath, 28 | use: [ 29 | 'style-loader', 30 | { loader: 'css-loader', options: { url: true, importLoaders: 1, modules: { mode: 'local' } } }, 31 | { 32 | loader: 'sass-loader', 33 | options: { implementation: require('sass'), sassOptions: { includePaths: ['src/styles'] } } 34 | } 35 | ] 36 | }, 37 | { test: /\.svg$/iu, use: ['@svgr/webpack'] }, 38 | { test: /\.(glb|gltf|bin|fbx|hdr|exr|woff2|riv|wasm)$/iu, type: 'asset/resource' }, 39 | { test: /\.(glsl|hlsl|vert|frag)$/iu, type: 'asset/source' } 40 | ) 41 | } 42 | 43 | config.plugins?.push(new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] })) 44 | 45 | return config 46 | } 47 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/.stylelintignore -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "scss.lint.unknownAtRules": "ignore", 3 | "typescript.preferences.importModuleSpecifier": "non-relative" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-present, Experience.Monks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/asset-management.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /docs/configuring-analytics.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /docs/environment-variables.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/media-queries.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /docs/state-management.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | 3 | // for more config options; 4 | // https://www.npmjs.com/package/next-sitemap#configuration-options 5 | 6 | const config = require('./src/data/config.json') 7 | 8 | module.exports = { 9 | // ======================== 10 | // sitemap.xml 11 | // ======================== 12 | siteUrl: config.websiteUrl || 'https://something-is-wrong-if-you-see-this.com', 13 | changefreq: 'daily', // always hourly daily weekly monthly yearly never 14 | priority: 0.7, // between 0 and 1 15 | sitemapSize: 5000, 16 | exclude: [ 17 | '/unsupported' 18 | // '/page-0' 19 | // '/page-*' 20 | // '/private/*' 21 | ], 22 | alternateRefs: [ 23 | // multi-language support 24 | // { 25 | // href: 'https://example.com/fr', 26 | // hreflang: 'fr' 27 | // } 28 | ], 29 | // transform: async (config, path) => {}, 30 | // additionalPaths: async (config) => {}, 31 | outDir: 'out', 32 | autoLastmod: true, 33 | generateIndexSitemap: false, 34 | // ======================== 35 | // robots.txt 36 | // ======================== 37 | generateRobotsTxt: true, 38 | robotsTxtOptions: { 39 | policies: [ 40 | { 41 | userAgent: '*', 42 | allow: '/', 43 | disallow: ['/_next', '/404', '/500', '/unsupported', '/favicons'] 44 | } 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const path = require('node:path') 4 | const withVideos = require('next-videos') 5 | 6 | const nextConfig = { 7 | output: process.env.OUTPUT, 8 | assetPrefix: process.env.NEXT_PUBLIC_ASSET_PREFIX || undefined, 9 | trailingSlash: true, 10 | reactStrictMode: false, 11 | sassOptions: { includePaths: [path.join(__dirname, 'src/styles')] }, 12 | webpack(config) { 13 | config.module.rules.push( 14 | { test: /\.svg$/iu, use: [{ loader: '@svgr/webpack' }] }, 15 | { test: /\.(glb|gltf|bin|fbx|hdr|exr|woff2|riv|wasm)$/iu, type: 'asset/resource' }, 16 | { test: /\.(glsl|hlsl|vert|frag)$/iu, type: 'asset/source' } 17 | ) 18 | return config 19 | } 20 | } 21 | 22 | const nextPlugins = [withVideos] 23 | 24 | if (process.env.BUNDLE_ANALYZE === 'true') { 25 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: true }) 26 | nextPlugins.push(withBundleAnalyzer) 27 | } 28 | 29 | const finalConfig = nextPlugins.reduce((config, plugin) => { 30 | if (typeof plugin === 'function') return plugin(config) 31 | return plugin[0]({ ...config, ...plugin[1] }) 32 | }, nextConfig) 33 | 34 | module.exports = finalConfig 35 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/common/assets/images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/assets/images/.gitkeep -------------------------------------------------------------------------------- /public/common/assets/images/share-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/assets/images/share-image.jpg -------------------------------------------------------------------------------- /public/common/assets/sounds/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/assets/sounds/.gitkeep -------------------------------------------------------------------------------- /public/common/assets/videos/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/assets/videos/.gitkeep -------------------------------------------------------------------------------- /public/common/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/common/favicons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ffc40d 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/common/favicons/favicon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/favicon-144x144.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/favicon-150x150.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/favicon-192x192.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/favicon-384x384.png -------------------------------------------------------------------------------- /public/common/favicons/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/favicon-512x512.png -------------------------------------------------------------------------------- /public/common/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/public/common/favicons/favicon.ico -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # Development configuration 2 | User-agent: * 3 | Disallow: / 4 | -------------------------------------------------------------------------------- /scripts/dev-server/certificates/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC5TCCAc2gAwIBAgIJALF5imJVTvKhMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV 3 | BAMMCWxvY2FsaG9zdDAeFw0yMDA3MjkyMDIzMDhaFw0zMDA3MjcyMDIzMDhaMBQx 4 | EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 5 | ggEBAKjSOYO2usLQ7lcbi3QYwY3mWCpFEBouGsbaR3SP06olPFbZOcYtISB+djRp 6 | 7HFYM/6NqoVhop5B7dbcZ56zaZ6rY2xK1eZim7iLfHbuc+7Yu9sFzjrj0ugA4krv 7 | vMB4AFq1aWL9EHze+Pn/H0lgHiqkAaL5HJ6YJ84IGtIULoxlV8wIWWP+PSPETI5Y 8 | TPZEfkDmTWCyz8tuXaYHTcSpYz1AYYU39oYhg5YhYgITmLPrU6n9DDRvbkQ0J417 9 | RyyweTCalm4JoQcjURAbSnjFTCt2izBusv6QL887Jeh9MvjOm+LpxuU25lSedY3F 10 | H9gfOvHUF14hM3YM105REo0vJKUCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo 11 | b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B 12 | AQsFAAOCAQEAjxLSVLFSFMmZce7LIUWIVCj7jKwiba6HmCLS2hYiFQ2nyNpprhFm 13 | lGzEV1ThBCXgtgxo1zvGfBXgmjHt8eXBvZ4cWJ/aF0eIkNykYac5f1ecHYVWjfp7 14 | HD9W1KzuGd1k0BpdjxgL1yR3Fhlaq13jjaxHtia8L7S9HUq3RxnJEvM7yP9zc2GO 15 | AevM+WMEe+HVQqtiPjgTHkKNoiywNw61MWPAs8ySdk52lIlsv6XKG1JKfU9gMcL6 16 | RxZeXTQrqZVxbrhI8aXK/Q7lzl4GQOEEp5vsagicjeFw/mwXgVTfmMI2dIPIZaU6 17 | fnFDwWc7/Ohin2CRQDZkuMDFbAtEtcutzA== 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | ) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/public-image-sizes.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs') 2 | const path = require('node:path') 3 | const sizeOf = require('image-size') 4 | 5 | const publicPath = path.join(__dirname, '../public') 6 | 7 | const output = {} 8 | 9 | function readRecursively(directory) { 10 | const dirents = fs.readdirSync(directory, { withFileTypes: true }) 11 | 12 | for (const dirent of dirents) { 13 | const item = path.join(directory, dirent.name) 14 | if (dirent.isDirectory()) { 15 | readRecursively(item) 16 | } else if (/.(bmp|cur|dds|gif|icns|ico|jpg|jpeg|ktx|png|pnm|pam|pbm|pfm|pgm|ppm|psd|svg|tiff|webp)$/iu.test(item)) { 17 | const size = sizeOf(item) 18 | output[item.replace(publicPath, '').replace(/\\/gu, '/')] = { width: size.width, height: size.height } 19 | } 20 | } 21 | 22 | fs.mkdirSync(path.join(__dirname, '../.generated/'), { recursive: true }) 23 | fs.writeFileSync( 24 | path.join(__dirname, '../.generated/public-image-sizes.ts'), 25 | `export const publicImageSizes = ${JSON.stringify(output, undefined, 2)}\n` 26 | ) 27 | } 28 | 29 | console.log('[SCRIPTS] Generating .generated/public-image-sizes.json') 30 | 31 | readRecursively(publicPath) 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/templates/component.controller.tsx.hbs: -------------------------------------------------------------------------------- 1 | {{#if forwardRef}} 2 | {{#if imperativeHandle}} 3 | import type { ForwardedRef } from 'react' 4 | import type { ViewHandle } from './{{titleCase name}}.view' 5 | {{/if}} 6 | 7 | import { forwardRef, memo } from 'react' 8 | {{else}} 9 | {{#if imperativeHandle}} 10 | import type { FC, ForwardedRef } from 'react' 11 | import type { ViewHandle } from './{{titleCase name}}.view' 12 | {{else}} 13 | import type { FC } from 'react' 14 | {{/if}} 15 | 16 | import { memo } from 'react' 17 | {{/if}} 18 | 19 | import { View } from './{{titleCase name}}.view' 20 | 21 | export interface ControllerProps { 22 | className?: string 23 | {{#if imperativeHandle}} 24 | handleRef?: ForwardedRef 25 | {{/if}} 26 | } 27 | 28 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 29 | {{#if forwardRef}} 30 | export const Controller = memo(forwardRef((props, ref) => { 31 | return 32 | })) 33 | {{else}} 34 | export const Controller: FC = memo((props) => { 35 | return 36 | }) 37 | {{/if}} 38 | 39 | Controller.displayName = '{{titleCase name}}_Controller' 40 | -------------------------------------------------------------------------------- /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/templates/component.module.scss.hbs: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | display: block; 5 | {{#if transitionPresence}} 6 | opacity: 0; 7 | {{/if}} 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/templates/page.module.scss.hbs: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | width: 100%; 5 | opacity: 0; 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/templates/page.view.tsx.hbs: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { ControllerProps } from './Page{{titleCase name}}.controller' 3 | 4 | import { useMemo } from 'react' 5 | import classNames from 'classnames' 6 | import { gsap } from 'gsap' 7 | 8 | import css from './Page{{titleCase name}}.module.scss' 9 | 10 | import { copy } from '@/utils/copy' 11 | 12 | import { useRefs } from '@/hooks/use-refs' 13 | import { useTransitionPresence } from '@/hooks/use-transition-presence' 14 | 15 | export interface ViewProps extends ControllerProps {} 16 | 17 | export type ViewRefs = { 18 | root: HTMLElement 19 | } 20 | 21 | // View (pure and testable component, receives props exclusively from the controller) 22 | export const View: FC = ({ content }) => { 23 | const refs = useRefs() 24 | 25 | useTransitionPresence( 26 | useMemo( 27 | () => ({ 28 | animateIn: () => gsap.timeline().to(refs.root.current, { opacity: 1 }, 0), 29 | animateOut: () => gsap.timeline().to(refs.root.current, { opacity: 0 }, 0) 30 | }), 31 | [refs] 32 | ) 33 | ) 34 | 35 | return ( 36 |
37 |

38 |

39 | ) 40 | } 41 | 42 | View.displayName = 'Page{{titleCase name}}_View' 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/assets/fonts/.gitkeep: -------------------------------------------------------------------------------- 1 | // Try to use the less amount of custom fonts possibles :-) 2 | -------------------------------------------------------------------------------- /src/assets/images/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/src/assets/images/.gitkeep -------------------------------------------------------------------------------- /src/assets/images/experience-logo-500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/src/assets/images/experience-logo-500.png -------------------------------------------------------------------------------- /src/assets/images/github-icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/src/assets/images/github-icon-64.png -------------------------------------------------------------------------------- /src/assets/images/github-icon-64b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/src/assets/images/github-icon-64b.png -------------------------------------------------------------------------------- /src/assets/images/npm-icon-64b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/src/assets/images/npm-icon-64b.png -------------------------------------------------------------------------------- /src/assets/images/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/src/assets/images/test.png -------------------------------------------------------------------------------- /src/assets/images/x-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/src/assets/images/x-logo.png -------------------------------------------------------------------------------- /src/assets/rive/x-intro.riv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Experience-Monks/nextjs-boilerplate/a3808770a04935b469a6fad9528b82e0d4237913/src/assets/rive/x-intro.riv -------------------------------------------------------------------------------- /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/components/AppAdmin/AppAdmin.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | position: fixed; 5 | bottom: 0; 6 | right: 0; 7 | font-size: 12px; 8 | font-family: monospace !important; 9 | text-align: left; 10 | background: rgba($black, 0.8); 11 | border-radius: 10px 0 0; 12 | color: $white; 13 | z-index: 00110011; 14 | 15 | .basic { 16 | display: flex; 17 | align-items: center; 18 | white-space: nowrap; 19 | 20 | &.open { 21 | padding: 0 0 0 10px; 22 | 23 | div { 24 | margin: 0 auto 0 0; 25 | } 26 | } 27 | 28 | button { 29 | @include flex-center; 30 | @include box(20px, 30px); 31 | } 32 | } 33 | 34 | .details { 35 | position: relative; 36 | border-top: 1px solid rgba($white, 0.2); 37 | 38 | .content { 39 | .section { 40 | padding: 0 10px; 41 | display: flex; 42 | flex-direction: column; 43 | 44 | &:not(:last-child) { 45 | padding-bottom: 5px; 46 | border-bottom: 1px solid rgba($white, 0.2); 47 | } 48 | 49 | .title { 50 | font-size: inherit; 51 | padding: 10px 0; 52 | cursor: pointer; 53 | } 54 | 55 | ul { 56 | li { 57 | padding: 0 0 5px 10px; 58 | 59 | label { 60 | display: flex; 61 | gap: 5px; 62 | } 63 | } 64 | } 65 | 66 | .reset { 67 | margin: 0 0 0 auto; 68 | } 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /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/AppAdmin/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as AppAdminProps } from './AppAdmin.controller' 2 | 3 | export { Controller as AppAdmin } from './AppAdmin.controller' 4 | -------------------------------------------------------------------------------- /src/components/BaseButton/BaseButton.controller.tsx: -------------------------------------------------------------------------------- 1 | import type { FocusEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' 2 | import type { GTMEvent } from '@/services/analytics.service' 3 | import type { UrlObject } from 'node:url' 4 | 5 | import { forwardRef, memo } from 'react' 6 | 7 | import { View } from './BaseButton.view' 8 | 9 | export interface ControllerProps { 10 | children: ReactNode 11 | className?: string 12 | download?: boolean 13 | disabled?: boolean 14 | tabIndex?: number 15 | subject?: string 16 | target?: string 17 | title?: string 18 | href?: string | UrlObject 19 | link?: string | UrlObject 20 | role?: string 21 | rel?: string 22 | id?: string 23 | type?: 'submit' | 'reset' | 'button' 24 | gtmEvent?: GTMEvent 25 | onClick?: (event?: MouseEvent) => void 26 | onFocus?: (event?: FocusEvent) => void 27 | onKeyDown?: (event?: KeyboardEvent) => void 28 | onMouseEnter?: (event?: MouseEvent) => void 29 | onMouseLeave?: (event?: MouseEvent) => void 30 | 'aria-label'?: string 31 | } 32 | 33 | // Controller (handles global state, router, data fetching, etc. Feeds props to the view component) 34 | export const Controller = memo( 35 | forwardRef((props, ref) => { 36 | return 37 | }) 38 | ) 39 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /src/components/BaseImage/BaseImage.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | display: block; 5 | } 6 | -------------------------------------------------------------------------------- /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/BaseImage/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as BaseImageProps } from './BaseImage.controller' 2 | 3 | export { Controller as BaseImage } from './BaseImage.controller' 4 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/components/Head/MockContentSecurityPolicy.tsx: -------------------------------------------------------------------------------- 1 | import NextHead from 'next/head' 2 | /** 3 | * NOTE: 4 | * This ContentSecurityPolicy allows frontend developers to experience with Experience.Monks CSP rules in local environment. 5 | * The benefit is that frontend developer can identify what CSP problems will occur in the live environment in advance. 6 | * When modifying CSP content below, please tell TA or Devops developer in the project to update the Security Header lambda@Edge function. 7 | */ 8 | export const MockContentSecurityPolicy = () => { 9 | const content = ` 10 | default-src 11 | self; 12 | manifest-src 13 | 'self'; 14 | base-uri 15 | 'self'; 16 | form-action 17 | 'self'; 18 | font-src 19 | 'self' 20 | data: 21 | 'unsafe-inline'; 22 | object-src 23 | 'none'; 24 | media-src 25 | 'self'; 26 | img-src 27 | 'self' 28 | blob: 29 | data:; 30 | connect-src 31 | 'self' 32 | www.google-analytics.com; 33 | prefetch-src 34 | 'self'; 35 | script-src 36 | 'self' 37 | 'unsafe-eval' 38 | 'unsafe-inline' 39 | www.googletagmanager.com 40 | www.google-analytics.com 41 | www.google.com www.gstatic.com; 42 | style-src-elem 43 | 'self' 44 | blob: 45 | data: 46 | 'unsafe-inline'; 47 | style-src 48 | 'self' 49 | blob: 50 | data: 51 | 'unsafe-inline'; 52 | `.replaceAll(/(\r\n|\n|\r)/gmu, '') 53 | 54 | return ( 55 | 56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Head/MockFeaturePolicy.tsx: -------------------------------------------------------------------------------- 1 | import NextHead from 'next/head' 2 | 3 | /** 4 | * NOTE: 5 | * Experience.Monks Security Header Lambda@Edge function includes the same Feature Policy content below. 6 | * The benefit of having this rules in local environment is that frontend developer can identify what problems will occur in the live environment in advance. 7 | * When modifying Feature Policy content below, please tell TA or Devops developer in the project to update the Security Header lambda@Edge function. 8 | */ 9 | export const MockFeaturePolicy = () => { 10 | const content = ` 11 | sync-xhr 12 | 'none'; 13 | geolocation 14 | 'none'; 15 | midi 16 | 'none'; 17 | payment 18 | 'none'; 19 | camera 20 | 'none'; 21 | usb 22 | 'none'; 23 | fullscreen 24 | 'none'; 25 | magnetometer 26 | 'none'; 27 | picture-in-picture 28 | 'none'; 29 | accelerometer 30 | 'none'; 31 | autoplay 32 | 'none'; 33 | document-domain 34 | 'none'; 35 | encrypted-media 36 | 'none'; 37 | gyroscope 38 | 'none'; 39 | xr-spatial-tracking 40 | 'none'; 41 | microphone 42 | 'none'; 43 | `.replace(/(\r\n|\n|\r)/gmu, '') 44 | 45 | return ( 46 | 47 | ; 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /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/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/components/Nav/Nav.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | @include z-index(nav); 5 | @include flex-center-vert; 6 | position: fixed; 7 | top: 0; 8 | left: 0; 9 | width: 100%; 10 | padding: 0 px(20); 11 | box-shadow: $element-shadow; 12 | background-color: $white; 13 | height: $navbar-height-mobile; 14 | 15 | @include media-tablet { 16 | height: $navbar-height-desktop; 17 | } 18 | 19 | .skipToContent { 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | height: fit-content; 24 | pointer-events: none; 25 | opacity: 0.0001; 26 | } 27 | 28 | .skipToContent:focus, 29 | .skipToContent:active { 30 | color: $white; 31 | background-color: $black; 32 | opacity: 1; 33 | } 34 | 35 | > .wrapper { 36 | display: flex; 37 | max-width: px(1440); 38 | margin: 0 auto; 39 | width: 100%; 40 | 41 | > ul { 42 | @include flex-center-vert; 43 | flex-grow: 1; 44 | 45 | &.routes { 46 | justify-content: flex-start; 47 | 48 | .mainLogo { 49 | width: auto; 50 | height: px(35); 51 | } 52 | } 53 | 54 | &.links { 55 | justify-content: flex-end; 56 | 57 | a { 58 | img { 59 | @include box(px(25)); 60 | } 61 | } 62 | } 63 | 64 | > li { 65 | padding: 0 px(10); 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/PageAbout/PageAbout.view.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { ControllerProps } from './PageAbout.controller' 3 | 4 | import { useEffect, useMemo } from 'react' 5 | import classNames from 'classnames' 6 | import { gsap } from 'gsap' 7 | 8 | import css from './PageAbout.module.scss' 9 | 10 | import { copy } from '@/utils/copy' 11 | 12 | import { useRefs } from '@/hooks/use-refs' 13 | import { useTransitionPresence } from '@/hooks/use-transition-presence' 14 | 15 | export interface ViewProps extends ControllerProps {} 16 | 17 | export type ViewRefs = { 18 | root: HTMLImageElement 19 | } 20 | 21 | // View (pure and testable component, receives props exclusively from the controller) 22 | export const View: FC = ({ content }) => { 23 | const refs = useRefs() 24 | 25 | useEffect(() => { 26 | gsap.set(refs.root.current, { opacity: 0 }) 27 | }, [refs]) 28 | 29 | useTransitionPresence( 30 | useMemo( 31 | () => ({ 32 | animateIn: () => gsap.timeline().to(refs.root.current, { opacity: 1 }), 33 | animateOut: () => gsap.timeline().to(refs.root.current, { opacity: 0 }) 34 | }), 35 | [refs] 36 | ) 37 | ) 38 | 39 | return ( 40 |
41 |

42 |
43 |

44 | ) 45 | } 46 | 47 | View.displayName = 'PageAbout_View' 48 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/components/PageHome/PageHome.module.scss: -------------------------------------------------------------------------------- 1 | @import 'shared'; 2 | 3 | .root { 4 | position: relative; 5 | width: 100%; 6 | 7 | .hero { 8 | width: 100%; 9 | color: #333; 10 | 11 | .title { 12 | @include typography-h1; 13 | margin: 0; 14 | width: 100%; 15 | padding-top: px(80); 16 | padding-bottom: px(24); 17 | } 18 | 19 | .title, 20 | .description { 21 | text-align: center; 22 | } 23 | 24 | .row { 25 | max-width: px(800); 26 | margin: px(80) auto px(40); 27 | display: flex; 28 | align-items: stretch; 29 | justify-content: space-around; 30 | flex-wrap: wrap; 31 | 32 | li { 33 | margin: px(10); 34 | 35 | .card { 36 | display: block; 37 | width: px(220); 38 | height: 100%; 39 | padding: px(18) px(18) px(24); 40 | border: 1px solid #9b9b9b; 41 | text-align: left; 42 | text-decoration: none; 43 | color: #434343; 44 | 45 | &:hover { 46 | border-color: #067df7; 47 | } 48 | 49 | h3 { 50 | font-size: px(18); 51 | } 52 | 53 | p { 54 | @include typography-paragraph; 55 | padding: px(12) 0 0; 56 | color: #333; 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /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/PageHome/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as PageHomeProps } from './PageHome.controller' 2 | 3 | export { Controller as PageHome } from './PageHome.controller' 4 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/components/PageNotFound/PageNotFound.view.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import type { ControllerProps } from './PageNotFound.controller' 3 | 4 | import { useEffect, useMemo } from 'react' 5 | import classNames from 'classnames' 6 | import { gsap } from 'gsap' 7 | 8 | import css from './PageNotFound.module.scss' 9 | 10 | import { copy } from '@/utils/copy' 11 | 12 | import { useRefs } from '@/hooks/use-refs' 13 | import { useTransitionPresence } from '@/hooks/use-transition-presence' 14 | 15 | export interface ViewProps extends ControllerProps {} 16 | 17 | export type ViewRefs = { 18 | root: HTMLImageElement 19 | } 20 | 21 | // View (pure and testable component, receives props exclusively from the controller) 22 | export const View: FC = ({ content }) => { 23 | const refs = useRefs() 24 | 25 | useEffect(() => { 26 | gsap.set(refs.root.current, { opacity: 0 }) 27 | }, [refs]) 28 | 29 | useTransitionPresence( 30 | useMemo( 31 | () => ({ 32 | animateIn: () => gsap.timeline().to(refs.root.current, { opacity: 1 }), 33 | animateOut: () => gsap.timeline().to(refs.root.current, { opacity: 0 }) 34 | }), 35 | [refs] 36 | ) 37 | ) 38 | 39 | return ( 40 |
41 |

42 |

43 | ) 44 | } 45 | 46 | View.displayName = 'PageNotFound_View' 47 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/components/PageUnsupported/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as PageUnsupportedProps } from './PageUnsupported.controller' 2 | 3 | export { Controller as PageUnsupported } from './PageUnsupported.controller' 4 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/components/ScreenNoScript/index.ts: -------------------------------------------------------------------------------- 1 | export type { ControllerProps as ScreenNoScriptProps } from './ScreenNoScript.controller' 2 | 3 | export { Controller as ScreenNoScript } from './ScreenNoScript.controller' 4 | -------------------------------------------------------------------------------- /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/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/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/ScreenRotate/ScreenRotate.view.tsx: -------------------------------------------------------------------------------- 1 | import type { ControllerProps } from './ScreenRotate.controller' 2 | 3 | import { type FC, useEffect, useState } from 'react' 4 | import classNames from 'classnames' 5 | 6 | import css from './ScreenRotate.module.scss' 7 | 8 | import { ResizeService } from '@/services/resize.service' 9 | 10 | import { copy } from '@/utils/copy' 11 | import { device } from '@/utils/detect' 12 | 13 | import { useRefs } from '@/hooks/use-refs' 14 | 15 | export interface ViewProps extends ControllerProps {} 16 | 17 | export type ViewRefs = { 18 | root: HTMLDivElement 19 | } 20 | 21 | // View (pure and testable component, receives props exclusively from the controller) 22 | export const View: FC = ({ className, content }) => { 23 | const refs = useRefs() 24 | 25 | const [enable, setEnable] = useState(process.env.STORYBOOK || (!device.desktop && device.phone && device.landscape)) 26 | 27 | useEffect(() => { 28 | const handleResize = () => { 29 | setEnable(device.phone && device.landscape) 30 | } 31 | 32 | ResizeService.listen(handleResize) 33 | 34 | return () => { 35 | ResizeService.dismiss(handleResize) 36 | } 37 | }, []) 38 | 39 | return enable ? ( 40 |
41 |

42 |

43 |

44 | ) : null 45 | } 46 | 47 | View.displayName = 'ScreenRotate_View' 48 | -------------------------------------------------------------------------------- /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/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/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/hooks/use-before-unmount.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react' 2 | 3 | import { useContext, useEffect } from 'react' 4 | import { useRefValue } from '@mediamonks/react-hooks' 5 | 6 | import { TransitionContext } from '@/motion/transition/transition.context' 7 | 8 | export type BeforeUnmountCallback = ( 9 | abortSignal: AbortSignal 10 | ) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type 11 | PromiseLike | void 12 | 13 | /** 14 | * Executes async callback to defer unmounting of children in nearest 15 | * TransitionPresence boundary 16 | */ 17 | export function useBeforeUnmount(callback: BeforeUnmountCallback): void { 18 | const context = useContext(TransitionContext) 19 | const callbackRef = useRefValue(callback) 20 | 21 | if (context === undefined) console.warn('Component is not rendered in the context of a TransitionPresence') 22 | 23 | useEffect(() => { 24 | queueMicrotask(() => { 25 | context?.add(callbackRef) 26 | }) 27 | 28 | return () => { 29 | context?.delete(callbackRef) 30 | } 31 | }, [context, callbackRef]) 32 | } 33 | 34 | /** 35 | * useBeforeUnmount without the warning, this should only be used within the 36 | * component in this package. 37 | */ 38 | export function useTransitionPresenceBeforeUnmount(callback: BeforeUnmountCallback | undefined): void { 39 | const context = useContext(TransitionContext) 40 | const callbackRef = useRefValue(callback) 41 | 42 | useEffect(() => { 43 | if (!callbackRef?.current) return 44 | 45 | const ref = callbackRef as RefObject 46 | 47 | queueMicrotask(() => { 48 | context?.add(ref) 49 | }) 50 | 51 | return () => { 52 | context?.delete(ref) 53 | } 54 | }, [context, callbackRef]) 55 | } 56 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /src/hooks/use-intersection-observer.ts: -------------------------------------------------------------------------------- 1 | import type { MutableRefObject, RefObject } from 'react' 2 | 3 | import { useEffect, useState } from 'react' 4 | 5 | export function useIntersectionObserver( 6 | element: Element | RefObject | null | undefined, 7 | triggerOnce = true, 8 | threshold = 0.3 9 | ): boolean { 10 | const [intersecting, setIntersecting] = useState(false) 11 | 12 | useEffect(() => { 13 | let observer: IntersectionObserver 14 | 15 | if (element) { 16 | const el = (element as MutableRefObject).current || (element as Element) 17 | if (el?.tagName) { 18 | const options = { 19 | threshold, 20 | triggerOnce: Boolean(triggerOnce), 21 | rootMargin: '0px' 22 | } 23 | observer = new IntersectionObserver((entries) => { 24 | if (options.triggerOnce) { 25 | if (entries.some((e) => e.isIntersecting)) { 26 | setIntersecting(true) 27 | observer.unobserve(el) 28 | } 29 | } else { 30 | setIntersecting(entries[0].isIntersecting) 31 | } 32 | }, options) 33 | observer.observe(el) 34 | } 35 | } 36 | 37 | return () => { 38 | observer?.disconnect() 39 | } 40 | }, [element, threshold, triggerOnce]) 41 | 42 | return intersecting 43 | } 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/motion/effects/fade/fadeFrom/fadeFrom.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 { BaseImage } from '@/components/BaseImage' 9 | 10 | import fadeFrom from './fadeFrom' 11 | 12 | export default { title: 'motion/Effects/fade/fadeFrom' } 13 | 14 | gsap.registerEffect(fadeFrom) 15 | 16 | export const Default: StoryFn = (args) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.fadeFrom(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { clearProps: 'all' }) 26 | } 27 | }, [args]) 28 | 29 | return ( 30 |
31 | 32 |
33 | ) 34 | } 35 | 36 | Default.args = { 37 | duration: 1, 38 | ease: easeNames[0], 39 | x: 0, 40 | y: 40, 41 | scale: 1, 42 | rotate: 0 43 | } 44 | 45 | Default.argTypes = { 46 | ease: { options: easeNames, control: { type: 'select' } } 47 | } 48 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/motion/effects/fade/fadeIn/fadeIn.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 { BaseImage } from '@/components/BaseImage' 9 | 10 | import fadeIn from './fadeIn' 11 | 12 | export default { title: 'motion/Effects/fade/fadeIn' } 13 | 14 | gsap.registerEffect(fadeIn) 15 | 16 | export const Default: StoryFn = (args) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.fadeIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return ( 30 |
31 | 32 |
33 | ) 34 | } 35 | 36 | Default.args = { 37 | duration: 1, 38 | ease: easeNames[0] 39 | } 40 | 41 | Default.argTypes = { 42 | ease: { options: easeNames, control: { type: 'select' } } 43 | } 44 | -------------------------------------------------------------------------------- /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/_.d.ts: -------------------------------------------------------------------------------- 1 | interface CustomEffects { 2 | fadeOut: CustomEffect<{ duration: number; reversed: boolean; delay: number; stagger: number }> 3 | } 4 | -------------------------------------------------------------------------------- /src/motion/effects/fade/fadeOut/fadeOut.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 { BaseImage } from '@/components/BaseImage' 9 | 10 | import fadeOut from './fadeOut' 11 | 12 | export default { title: 'motion/Effects/fade/fadeOut' } 13 | 14 | gsap.registerEffect(fadeOut) 15 | 16 | export const Default: StoryFn = (args) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.fadeOut(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 1 }) 26 | } 27 | }, [args]) 28 | 29 | return ( 30 |
31 | 32 |
33 | ) 34 | } 35 | 36 | Default.args = { 37 | duration: 1, 38 | ease: easeNames[0] 39 | } 40 | 41 | Default.argTypes = { 42 | ease: { options: easeNames, control: { type: 'select' } } 43 | } 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/motion/effects/mask/maskWipeIn/maskWipeIn.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 { BaseImage } from '@/components/BaseImage' 9 | 10 | import maskWipeIn from './maskWipeIn' 11 | 12 | export default { title: 'motion/Effects/mask/maskWipeIn' } 13 | 14 | gsap.registerEffect(maskWipeIn) 15 | 16 | export const Default: StoryFn = (args) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.maskWipeIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | } 26 | }, [args]) 27 | 28 | return ( 29 |
30 | 31 |
32 | ) 33 | } 34 | 35 | Default.args = { 36 | ...maskWipeIn.defaults, 37 | duration: 1 38 | } 39 | 40 | Default.argTypes = { 41 | ease: { options: easeNames, control: { type: 'select' } }, 42 | direction: { options: ['left', 'right', 'up', 'down'], control: { type: 'select' } } 43 | } 44 | -------------------------------------------------------------------------------- /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/mask/maskWipeOut/maskWipeOut.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 { BaseImage } from '@/components/BaseImage' 9 | 10 | import maskWipeOut from './maskWipeOut' 11 | 12 | export default { title: 'motion/Effects/mask/maskWipeOut' } 13 | 14 | gsap.registerEffect(maskWipeOut) 15 | 16 | export const Default: StoryFn = (args) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.maskWipeOut(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | } 26 | }, [args]) 27 | 28 | return ( 29 |
30 | 31 |
32 | ) 33 | } 34 | 35 | Default.args = { 36 | ...maskWipeOut.defaults, 37 | duration: 1 38 | } 39 | 40 | Default.argTypes = { 41 | ease: { options: easeNames, control: { type: 'select' } }, 42 | direction: { options: ['left', 'right', 'up', 'down'], control: { type: 'select' } } 43 | } 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/textFadeByCharsIn/textFadeByCharsIn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textFadeByCharsIn from './textFadeByCharsIn' 11 | 12 | export default { title: 'motion/Effects/text/textFadeByCharsIn' } 13 | 14 | gsap.registerEffect(textFadeByCharsIn) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textFadeByCharsIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textFadeByCharsIn.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByCharsIn/textFadeByCharsIn.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 setup = (target: Element): [element: HTMLElement, split: SafeSplitText] => { 7 | const textElement = (target as unknown as HTMLElement[])[0] 8 | const splitText = new SafeSplitText(textElement, { type: 'words,chars' }) 9 | gsap.set(splitText.words, { opacity: 0 }) 10 | return [textElement, splitText] 11 | } 12 | 13 | const effect: CustomEffectConfig = { 14 | name: 'textFadeByCharsIn', 15 | effect: (target, config = {}) => { 16 | if (config.immediateRender) setup(target) 17 | 18 | return effectTimeline(config.duration!, config.reversed!, () => { 19 | const [element, split] = setup(target) 20 | const chars = config.shuffle ? gsap.utils.shuffle(split.chars) : split.chars 21 | return gsap 22 | .timeline({ paused: true }) 23 | .set(element, { opacity: 1 }) 24 | .set(split.words, { opacity: 1 }) 25 | .set(chars, { opacity: 0 }) 26 | .to(chars, { 27 | opacity: 1, 28 | duration: config.charDuration, 29 | stagger: config.charOffset, 30 | ease: config.ease 31 | }) 32 | }) 33 | }, 34 | defaults: { 35 | ease: 'none', 36 | duration: +(gsap.defaults().duration || 1), 37 | charDuration: 1, 38 | charOffset: 0.1, 39 | shuffle: false, 40 | immediateRender: false 41 | }, 42 | extendTimeline: true 43 | } 44 | 45 | export default effect 46 | -------------------------------------------------------------------------------- /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/textFadeByCharsOut/textFadeByCharsOut.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textFadeByCharsOut from './textFadeByCharsOut' 11 | 12 | export default { title: 'motion/Effects/text/textFadeByCharsOut' } 13 | 14 | gsap.registerEffect(textFadeByCharsOut) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textFadeByCharsOut(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 1 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textFadeByCharsOut.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByCharsOut/textFadeByCharsOut.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: 'textFadeByCharsOut', 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 | const chars = config.shuffle ? gsap.utils.shuffle(split.chars) : split.chars 13 | return gsap // 14 | .timeline({ paused: true }) 15 | .set(split.words, { overflow: 'hidden', display: 'inline-flex' }) 16 | .to(chars, { 17 | opacity: 0, 18 | duration: config.charDuration, 19 | stagger: config.charOffset, 20 | ease: config.ease 21 | }) 22 | }) 23 | }, 24 | defaults: { 25 | ease: 'none', 26 | duration: +(gsap.defaults().duration || 1), 27 | charDuration: 1, 28 | charOffset: 0.1, 29 | shuffle: false 30 | }, 31 | extendTimeline: true 32 | } 33 | 34 | export default effect 35 | -------------------------------------------------------------------------------- /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/textFadeByLinesIn/textFadeByLinesIn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textFadeByLinesIn from './textFadeByLinesIn' 11 | 12 | export default { title: 'motion/Effects/text/textFadeByLinesIn' } 13 | 14 | gsap.registerEffect(textFadeByLinesIn) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textFadeByLinesIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textFadeByLinesIn.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByLinesIn/textFadeByLinesIn.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 setup = (target: Element): [element: HTMLElement, split: SafeSplitText] => { 7 | const textEl = (target as unknown as HTMLElement[])[0] 8 | const splitTxt = new SafeSplitText(textEl, { type: 'lines' }) 9 | gsap.set(splitTxt.lines, { opacity: 0 }) 10 | return [textEl, splitTxt] 11 | } 12 | 13 | const effect: CustomEffectConfig = { 14 | name: 'textFadeByLinesIn', 15 | effect: (target, config = {}) => { 16 | if (config.immediateRender) setup(target) 17 | 18 | return effectTimeline(config.duration!, config.reversed!, () => { 19 | const [element, split] = setup(target) 20 | const lines = config.shuffle ? gsap.utils.shuffle(split.lines) : split.lines 21 | return gsap // 22 | .timeline({ paused: true }) 23 | .set(element, { opacity: 1 }) 24 | .to(lines, { 25 | opacity: 1, 26 | duration: config.lineDuration, 27 | stagger: config.lineOffset, 28 | ease: config.ease 29 | }) 30 | }) 31 | }, 32 | defaults: { 33 | ease: 'none', 34 | duration: +(gsap.defaults().duration || 1), 35 | lineDuration: 1, 36 | lineOffset: 0.2, 37 | shuffle: false, 38 | immediateRender: false 39 | }, 40 | extendTimeline: true 41 | } 42 | 43 | export default effect 44 | -------------------------------------------------------------------------------- /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/textFadeByLinesOut/textFadeByLinesOut.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textFadeByLinesOut from './textFadeByLinesOut' 11 | 12 | export default { title: 'motion/Effects/text/textFadeByLinesOut' } 13 | 14 | gsap.registerEffect(textFadeByLinesOut) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textFadeByLinesOut(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 1 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textFadeByLinesOut.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByLinesOut/textFadeByLinesOut.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: 'textFadeByLinesOut', 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' }) 12 | const lines = config.shuffle ? gsap.utils.shuffle(split.lines) : split.lines 13 | return gsap // 14 | .timeline({ paused: true }) 15 | .to(lines, { 16 | opacity: 0, 17 | duration: config.lineDuration, 18 | stagger: config.lineOffset, 19 | ease: config.ease 20 | }) 21 | }) 22 | }, 23 | defaults: { 24 | ease: 'none', 25 | duration: +(gsap.defaults().duration || 1), 26 | lineDuration: 1, 27 | lineOffset: 0.2, 28 | shuffle: false 29 | }, 30 | extendTimeline: true 31 | } 32 | 33 | export default effect 34 | -------------------------------------------------------------------------------- /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/motion/effects/text/textFadeByWordsIn/textFadeByWordsIn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textFadeByWordsIn from './textFadeByWordsIn' 11 | 12 | export default { title: 'motion/Effects/text/textFadeByWordsIn' } 13 | 14 | gsap.registerEffect(textFadeByWordsIn) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textFadeByWordsIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textFadeByWordsIn.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByWordsIn/textFadeByWordsIn.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 setup = (target: Element): [element: HTMLElement, split: SafeSplitText] => { 7 | const element = (target as unknown as HTMLElement[])[0] 8 | const split = new SafeSplitText(element, { type: 'lines,words' }) 9 | gsap.set(split.lines, { opacity: 0 }) 10 | return [element, split] 11 | } 12 | 13 | const effect: CustomEffectConfig = { 14 | name: 'textFadeByWordsIn', 15 | effect: (target, config = {}) => { 16 | if (config.immediateRender) setup(target) 17 | 18 | return effectTimeline(config.duration!, config.reversed!, () => { 19 | const [element, split] = setup(target) 20 | const words = config.shuffle ? gsap.utils.shuffle(split.words) : split.words 21 | return gsap 22 | .timeline({ paused: true }) 23 | .set(element, { opacity: 1 }) 24 | .set(split.lines, { opacity: 1 }) 25 | .set(words, { opacity: 0 }) 26 | .to(words, { 27 | opacity: 1, 28 | duration: config.wordDuration, 29 | stagger: config.wordOffset, 30 | ease: config.ease 31 | }) 32 | }) 33 | }, 34 | defaults: { 35 | ease: 'none', 36 | duration: +(gsap.defaults().duration || 1), 37 | wordDuration: 1, 38 | wordOffset: 0.2, 39 | shuffle: false, 40 | immediateRender: false 41 | }, 42 | extendTimeline: true 43 | } 44 | 45 | export default effect 46 | -------------------------------------------------------------------------------- /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/textFadeByWordsOut/textFadeByWordsOut.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textFadeByWordsOut from './textFadeByWordsOut' 11 | 12 | export default { title: 'motion/Effects/text/textFadeByWordsOut' } 13 | 14 | gsap.registerEffect(textFadeByWordsOut) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textFadeByWordsOut(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 1 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textFadeByWordsOut.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textFadeByWordsOut/textFadeByWordsOut.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: 'textFadeByWordsOut', 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 | const words = config.shuffle ? gsap.utils.shuffle(split.words) : split.words 13 | return gsap // 14 | .timeline({ paused: true }) 15 | .to(words, { 16 | opacity: 0, 17 | duration: config.wordDuration, 18 | stagger: config.wordOffset, 19 | ease: config.ease 20 | }) 21 | }) 22 | }, 23 | defaults: { 24 | ease: 'none', 25 | duration: +(gsap.defaults().duration || 1), 26 | wordDuration: 1, 27 | wordOffset: 0.2, 28 | shuffle: false 29 | }, 30 | extendTimeline: true 31 | } 32 | 33 | export default effect 34 | -------------------------------------------------------------------------------- /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/textRiseByCharsIn/textRiseByCharsIn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textRiseByCharsIn from './textRiseByCharsIn' 11 | 12 | export default { title: 'motion/Effects/text/textRiseByCharsIn' } 13 | 14 | gsap.registerEffect(textRiseByCharsIn) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textRiseByCharsIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textRiseByCharsIn.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByCharsIn/textRiseByCharsIn.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 setup = (target: Element): [element: HTMLElement, split: SafeSplitText] => { 7 | const tgtElement = (target as unknown as HTMLElement[])[0] 8 | const splitText = new SafeSplitText(tgtElement, { type: 'words,chars' }) 9 | gsap.set(splitText.words, { opacity: 0 }) 10 | return [tgtElement, splitText] 11 | } 12 | 13 | const effect: CustomEffectConfig = { 14 | name: 'textRiseByCharsIn', 15 | effect: (target, config = {}) => { 16 | if (config.immediateRender) setup(target) 17 | 18 | return effectTimeline(config.duration!, config.reversed!, () => { 19 | const [element, split] = setup(target) 20 | return gsap 21 | .timeline({ paused: true }) 22 | .set(element, { opacity: 1 }) 23 | .set(split.words, { opacity: 1 }) 24 | .set(split.chars, { yPercent: 105 }) 25 | .set(split.words, { overflow: 'hidden', display: 'inline-flex' }) 26 | .to(split.chars, { 27 | yPercent: 0, 28 | duration: config.charDuration, 29 | stagger: config.charOffset, 30 | ease: config.ease 31 | }) 32 | }) 33 | }, 34 | defaults: { 35 | ease: 'expo.out', 36 | duration: +(gsap.defaults().duration || 1), 37 | charDuration: 1, 38 | charOffset: 0.01, 39 | immediateRender: false 40 | }, 41 | extendTimeline: true 42 | } 43 | 44 | export default effect 45 | -------------------------------------------------------------------------------- /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/textRiseByCharsOut/textRiseByCharsOut.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textRiseByCharsOut from './textRiseByCharsOut' 11 | 12 | export default { title: 'motion/Effects/text/textRiseByCharsOut' } 13 | 14 | gsap.registerEffect(textRiseByCharsOut) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textRiseByCharsOut(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 1 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textRiseByCharsOut.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /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/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/textRiseByLinesIn/textRiseByLinesIn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textRiseByLinesIn from './textRiseByLinesIn' 11 | 12 | export default { title: 'motion/Effects/text/textRiseByLinesIn' } 13 | 14 | gsap.registerEffect(textRiseByLinesIn) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textRiseByLinesIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textRiseByLinesIn.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /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/textRiseByLinesOut/textRiseByLinesOut.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textRiseByLinesOut from './textRiseByLinesOut' 11 | 12 | export default { title: 'motion/Effects/text/textRiseByLinesOut' } 13 | 14 | gsap.registerEffect(textRiseByLinesOut) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textRiseByLinesOut(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 1 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textRiseByLinesOut.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByLinesOut/textRiseByLinesOut.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: 'textRiseByLinesOut', 8 | effect: (target, config = {}) => { 9 | return effectTimeline(config.duration!, config.reversed!, () => { 10 | const element = (target as unknown as HTMLElement[])[0] 11 | const split1 = new SafeSplitText(element, { type: 'lines' }) 12 | const split2 = new SafeSplitText(split1.lines, { type: 'lines' }) 13 | return gsap // 14 | .timeline({ paused: true }) 15 | .set(split1.lines, { overflow: 'hidden' }) 16 | .to(split2.lines, { 17 | yPercent: 105, 18 | duration: config.lineDuration, 19 | stagger: config.lineOffset, 20 | ease: config.ease 21 | }) 22 | }) 23 | }, 24 | defaults: { 25 | ease: 'expo.in', 26 | duration: +(gsap.defaults().duration || 1), 27 | lineDuration: 1, 28 | lineOffset: 0.1 29 | }, 30 | extendTimeline: true 31 | } 32 | 33 | export default effect 34 | -------------------------------------------------------------------------------- /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/text/textRiseByWordsIn/textRiseByWordsIn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textRiseByWordsIn from './textRiseByWordsIn' 11 | 12 | export default { title: 'motion/Effects/text/textRiseByWordsIn' } 13 | 14 | gsap.registerEffect(textRiseByWordsIn) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textRiseByWordsIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textRiseByWordsIn.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByWordsIn/textRiseByWordsIn.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 setup = (target: Element): [element: HTMLElement, split: SafeSplitText] => { 7 | const el = (target as unknown as HTMLElement[])[0] 8 | const s = new SafeSplitText(el, { type: 'lines,words' }) 9 | gsap.set(s.lines, { opacity: 0 }) 10 | return [el, s] 11 | } 12 | 13 | const effect: CustomEffectConfig = { 14 | name: 'textRiseByWordsIn', 15 | effect: (target, config = {}) => { 16 | if (config.immediateRender) setup(target) 17 | return effectTimeline(config.duration!, config.reversed!, () => { 18 | const [element, split] = setup(target) 19 | return gsap 20 | .timeline({ paused: true }) 21 | .set(element, { opacity: 1 }) 22 | .set(split.lines, { opacity: 1 }) 23 | .set(split.words, { yPercent: 105 }) 24 | .set(split.lines, { overflow: 'hidden' }) 25 | .to(split.words, { 26 | yPercent: 0, 27 | duration: config.wordDuration, 28 | stagger: config.wordOffset, 29 | ease: config.ease 30 | }) 31 | }) 32 | }, 33 | defaults: { 34 | ease: 'expo.out', 35 | duration: +(gsap.defaults().duration || 1), 36 | wordDuration: 1, 37 | wordOffset: 0.1, 38 | immediateRender: false 39 | }, 40 | extendTimeline: true 41 | } 42 | 43 | export default effect 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseByWordsOut/textRiseByWordsOut.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textRiseByWordsOut from './textRiseByWordsOut' 11 | 12 | export default { title: 'motion/Effects/text/textRiseByWordsOut' } 13 | 14 | gsap.registerEffect(textRiseByWordsOut) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textRiseByWordsOut(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 1 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textRiseByWordsOut.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /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/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/textRiseFadeByLinesIn/textRiseFadeByLinesIn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textRiseFadeByLinesIn from './textRiseFadeByLinesIn' 11 | 12 | export default { title: 'motion/Effects/text/textRiseFadeByLinesIn' } 13 | 14 | gsap.registerEffect(textRiseFadeByLinesIn) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textRiseFadeByLinesIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textRiseFadeByLinesIn.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /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/text/textRiseFadeByWordsIn/textRiseFadeByWordsIn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textRiseFadeByWordsIn from './textRiseFadeByWordsIn' 11 | 12 | export default { title: 'motion/Effects/text/textRiseFadeByWordsIn' } 13 | 14 | gsap.registerEffect(textRiseFadeByWordsIn) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textRiseFadeByWordsIn(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | if (el) gsap.set(el, { opacity: 0 }) 26 | } 27 | }, [args]) 28 | 29 | return
30 | } 31 | 32 | Default.args = { 33 | ...textRiseFadeByWordsIn.defaults, 34 | duration: 1, 35 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 36 | } 37 | 38 | Default.argTypes = { 39 | ease: { options: easeNames, control: { type: 'select' } } 40 | } 41 | -------------------------------------------------------------------------------- /src/motion/effects/text/textRiseFadeByWordsIn/textRiseFadeByWordsIn.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 setup = (target: Element): [element: HTMLElement, split: SafeSplitText] => { 7 | const textEl = (target as unknown as HTMLElement[])[0] 8 | const splitTx = new SafeSplitText(textEl, { type: 'lines,words' }) 9 | gsap.set(splitTx.lines, { opacity: 0 }) 10 | return [textEl, splitTx] 11 | } 12 | 13 | const effect: CustomEffectConfig = { 14 | name: 'textRiseFadeByWordsIn', 15 | effect: (target, config = {}) => { 16 | if (config.immediateRender) setup(target) 17 | 18 | return effectTimeline(config.duration!, config.reversed!, () => { 19 | const [element, split] = setup(target) 20 | return gsap 21 | .timeline({ paused: true }) 22 | .set(element, { opacity: 1 }) 23 | .set(split.lines, { opacity: 1 }) 24 | .set(split.words, { y: config.y, opacity: 0 }) 25 | .to(split.words, { 26 | y: 0, 27 | duration: config.wordDuration, 28 | stagger: config.wordOffset, 29 | ease: config.ease 30 | }) 31 | .to( 32 | split.words, 33 | { 34 | opacity: 1, 35 | ease: 'none', 36 | duration: config.wordDuration! / 2, 37 | stagger: config.wordOffset 38 | }, 39 | '<' 40 | ) 41 | }) 42 | }, 43 | defaults: { 44 | ease: 'expo.out', 45 | duration: +(gsap.defaults().duration || 1), 46 | wordDuration: 1, 47 | wordOffset: 0.1, 48 | y: '2rem', 49 | immediateRender: false 50 | }, 51 | extendTimeline: true 52 | } 53 | 54 | export default effect 55 | -------------------------------------------------------------------------------- /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/textScrambleByChars/textScrambleByChars.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textScrambleByChars from './textScrambleByChars' 11 | 12 | export default { title: 'motion/Effects/text/textScrambleByChars' } 13 | 14 | gsap.registerEffect(textScrambleByChars) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textScrambleByChars(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | } 26 | }, [args]) 27 | 28 | return
29 | } 30 | 31 | Default.args = { 32 | ...textScrambleByChars.defaults, 33 | duration: 2, 34 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 35 | } 36 | 37 | Default.argTypes = { 38 | ease: { options: easeNames, control: { type: 'select' } } 39 | } 40 | -------------------------------------------------------------------------------- /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/textScrambleByLines/textScrambleByLines.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textScrambleByLines from './textScrambleByLines' 11 | 12 | export default { title: 'motion/Effects/text/textScrambleByLines' } 13 | 14 | gsap.registerEffect(textScrambleByLines) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textScrambleByLines(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | } 26 | }, [args]) 27 | 28 | return
29 | } 30 | 31 | Default.args = { 32 | ...textScrambleByLines.defaults, 33 | duration: 2, 34 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 35 | } 36 | 37 | Default.argTypes = { 38 | ease: { options: easeNames, control: { type: 'select' } } 39 | } 40 | -------------------------------------------------------------------------------- /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/motion/effects/text/textScrambleByWords/textScrambleByWords.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { StoryFn } from '@storybook/react' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { gsap } from 'gsap' 5 | 6 | import { copy } from '@/utils/copy' 7 | 8 | import { easeNames } from '@/motion/eases/eases' 9 | 10 | import textScrambleByWords from './textScrambleByWords' 11 | 12 | export default { title: 'motion/Effects/text/textScrambleByWords' } 13 | 14 | gsap.registerEffect(textScrambleByWords) 15 | 16 | export const Default: StoryFn = ({ content, ...args }) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const timeline = gsap.timeline() 21 | const el = ref.current 22 | if (el) timeline.textScrambleByWords(el!, args, 0.4) 23 | return () => { 24 | timeline.kill() 25 | } 26 | }, [args]) 27 | 28 | return
29 | } 30 | 31 | Default.args = { 32 | ...textScrambleByWords.defaults, 33 | duration: 2, 34 | content: "The relentless pursuit of better.\nWe create modern experiences\nfor tomorrow's brands." 35 | } 36 | 37 | Default.argTypes = { 38 | ease: { options: easeNames, control: { type: 'select' } } 39 | } 40 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/motion/effects/timeline/timelineFromTo/timelineFromTo.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 { BaseImage } from '@/components/BaseImage' 9 | 10 | import timelineFromTo from './timelineFromTo' 11 | 12 | export default { title: 'motion/Effects/timeline/timelineFromTo' } 13 | 14 | gsap.registerEffect(timelineFromTo) 15 | 16 | export const Default: StoryFn = (args) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const target = gsap.timeline({ paused: true }).fromTo(ref.current, { rotate: 0 }, { duration: 123, rotate: 360 }) 21 | 22 | const timeline = gsap.timeline() 23 | if (ref.current) timeline.timelineFromTo(target, args, 0.4) 24 | 25 | return () => { 26 | timeline.kill() 27 | } 28 | }, [args]) 29 | 30 | return ( 31 |
32 | 33 |
34 | ) 35 | } 36 | 37 | Default.args = { 38 | duration: 2, 39 | from: 0, 40 | to: 1, 41 | ease: 'expo.inOut' 42 | } 43 | 44 | Default.argTypes = { 45 | ease: { options: easeNames, control: { type: 'select' } } 46 | } 47 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/motion/effects/timeline/timelineTo/timelineTo.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 { BaseImage } from '@/components/BaseImage' 9 | 10 | import timelineTo from './timelineTo' 11 | 12 | export default { title: 'motion/Effects/timeline/timelineTo' } 13 | 14 | gsap.registerEffect(timelineTo) 15 | 16 | export const Default: StoryFn = (args) => { 17 | const ref = useRef(null) 18 | 19 | useEffect(() => { 20 | const target = gsap.timeline({ paused: true }).fromTo(ref.current, { rotate: 0 }, { duration: 123, rotate: 360 }) 21 | 22 | const timeline = gsap.timeline() 23 | if (ref.current) timeline.timelineTo(target, args, 0.4) 24 | 25 | return () => { 26 | timeline.kill() 27 | } 28 | }, [args]) 29 | 30 | return ( 31 |
32 | 33 |
34 | ) 35 | } 36 | 37 | Default.args = { 38 | duration: 2, 39 | to: 1, 40 | ease: 'expo.inOut' 41 | } 42 | 43 | Default.argTypes = { 44 | ease: { options: easeNames, control: { type: 'select' } } 45 | } 46 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 |