diff --git a/.codeclimate.yml b/.codeclimate.yml
index dab96e409..c60da8a7b 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -16,7 +16,7 @@ checks:
method-complexity:
enabled: true
config:
- threshold: 10
+ threshold: 25 # 10 by default
method-count:
enabled: true
config:
@@ -24,7 +24,7 @@ checks:
method-lines:
enabled: true
config:
- threshold: 100 # 25 by default
+ threshold: 300 # 25 by default
nested-control-flow:
enabled: true
config:
@@ -62,7 +62,9 @@ plugins:
exclude_patterns:
- "**/*.test.*"
- "**/*.spec.*"
- - "src/svg/"
+ - "svg-to-react/"
+ - "cypress/"
+ - "src/modules/core/testing/mocks"
# Default CC excluded paths:
- "config/"
diff --git a/.env b/.env
new file mode 100644
index 000000000..405c04cf0
--- /dev/null
+++ b/.env
@@ -0,0 +1,55 @@
+# See https://nextjs.org/docs/basic-features/environment-variables
+
+# XXX Tips: How is this file meant to be used?
+# This file is tracked by git and must only contains NON-SENSITIVE information, which is usually meant to be available in the browser.
+# Sensitive information (server-side only) MUST be written in ".env.local" file instead (which isn't tracked by git).
+
+# XXX Tips: When is this file being used?
+# This file is used only when building the Next.js app locally (localhost), whether it's for running `next dev` or `next build`.
+# For staging/production stages, the app relies on "vercel.{NEXT_PUBLIC_CUSTOMER_REF}.{NEXT_PUBLIC_APP_STAGE}.yml:build.env".
+
+# XXX Tips: What's the difference between env vars starting with "NEXT_PUBLIC_" and the others?
+# All env variables that DON'T start with "NEXT_PUBLIC_" MUST be manually exposed by ./next.config.js for the project to work locally
+# "NEXT_PUBLIC_" has a semantic purpose. If you mean to use a variable on the browser, then you should use "NEXT_PUBLIC_".
+# Any non-sensitive env variable should start with "NEXT_PUBLIC_".
+# Sensitive information MUST NOT start with "NEXT_PUBLIC_".
+# You must be careful to use sensitive information only on the server-side, because if you use them on the browser or getInitialProps, they'll be leaked, even if the variable doesn't start with "NEXT_PUBLIC_".
+# Any change to this file needs a server restart to be applied.
+
+# The stage is "how" the application is running.
+# It can be either "development", "staging" or "production".
+# This value is also set in each "vercel.*.json" files, so that other stages use their own value.
+# Tip: This value must not be changed.
+# Tip: You may override it from ".env.local" if you want to simulate another stage, locally.
+NEXT_PUBLIC_APP_STAGE=development
+
+# The name of the NRN preset being used.
+# Used by the demo to redirect to the preset branch/documentation.
+NEXT_PUBLIC_NRN_PRESET=v2-mst-aptd-gcms-lcz-sty
+
+# The customer that is being used.
+# Tip: You may override it from ".env.local" if you want to simulate another customer, locally.
+NEXT_PUBLIC_CUSTOMER_REF=customer1
+
+# Locize project ID, can be found in the project "settings" page
+# Used to fetch the i18n translations
+# Tip: The value being used below is valid, so that you can run the demo locally without having to create your own Locize account, but you cannot make any change
+NEXT_PUBLIC_LOCIZE_PROJECT_ID=658fc999-dfa8-4307-b9d7-b4870ad5b968
+
+# Amplitude API key, can be found under "Manage Data > Project name > Project settings > API Key"
+# Used to send analytics usage
+# Tip: The value being used below is valid, so that you can run the demo locally without having to create your own Amplitude account, but you cannot access the data
+NEXT_PUBLIC_AMPLITUDE_API_KEY=5ea02d86a6840c165fcc01377131fa13
+
+# GraphQL v2 API endpoint (using GraphCMS vendor)
+# Used to fetch content from GraphCMS
+# Tip: The value being used below is valid, so that you can run the demo locally without having to create your own GraphCMS account
+# XXX We only use one stage ("master") due to the free plan's limitation, but using two stages is more secure for enterprise-grade apps
+GRAPHQL_API_ENDPOINT=https://api-eu-central-1.graphcms.com/v2/ck73ixhlv09yt01dv2ga1bkbp/master
+
+# GraphQL API key (using GraphCMS vendor), can be found under "Settings > API Access > Permanent Auth Tokens"
+# Used to fetch content from GraphCMS, this token only has read-only permissions to avoid data tempering (especially since we made it public within NRN)
+# This variable is only used server-side
+# Tip: The value being used below is valid, so that you can run the demo locally without having to create your own GraphCMS account
+# XXX For the purpose of this demo, we're tracking this in git, but unless you're building an open API you'll want to move it to ".env.local" (especially if this is your "master" token)
+GRAPHQL_API_KEY=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImdjbXMtbWFpbi1wcm9kdWN0aW9uIn0.eyJ2ZXJzaW9uIjozLCJpYXQiOjE2MDYyMTEyNDgsImF1ZCI6WyJodHRwczovL2FwaS1ldS1jZW50cmFsLTEuZ3JhcGhjbXMuY29tL3YyL2NrNzNpeGhsdjA5eXQwMWR2MmdhMWJrYnAvbWFzdGVyIl0sImlzcyI6Imh0dHBzOi8vbWFuYWdlbWVudC5ncmFwaGNtcy5jb20vIiwic3ViIjoiMzRiNGMxZDgtNWY1Mi00MWM3LWJjNDYtMDM5MzM1NDc0OTVlIiwianRpIjoiY2todnNwZjA4bXg0aDAxdzliMmN6YXVwZCJ9.TfJDn4V9gGK5n7sI8limca4DnFjxREgsPDV5lf5yhA74BSwHq8XH-nU6cP18qf-IQ_wN7Ujw4MRY4LcazylR0IBli4dzrtKHZ0pEvZilZ0AfJ-80LBJQCVLNEiSuFKnIDUHe58MqMUZT8UoYFaWMH_cl7a3SOmdRCwsm93U5JYkjt7mqoH5Y-WaC3yl4Cq9IYexjHCJZLiFRZh8CL0jV6ddu8ghScegYTo_nJsZxHow12tuKI0I_3cJhdpcRL3HsTZWTQV8PPL0bhbLE1F7y0GksquKuSRScrTMBewYCgDWkxt7EcjRSuUR9WixQPVcTjHpnm_c3lL0HqUsDc_5aJV0POJUa9_T8m4_uFstZtBMtfmg6yBGOB8f06f27JyrCA5NZLNXMScmG06DbGOM8gbquN4-XigTj4t3jwSrzCRZ1zuFZEMoBruH0ElOqJ9hq05_WnGSpyD4NQl9l0bV1TXz5WMzoThlShrpL3f0A74ojXdel8awq9uTPB8qX7AAQa5m7n7lEmsD0tfom4ijiJeFdJakyNpoxY8TXPAgO819MPxE_z3qEynxu3QLS3jW2hc0mteLO0pNkl_twPGIwn7eBlv4u6S31pLXOT5aAIIMqsa1eoi8XwGKFLGhjZzDN_AsF-aBudoK8uhmNYCA7DyqWMXi2rvGyuYNU9Wu3XAs
diff --git a/.env.build.example b/.env.build.example
deleted file mode 100644
index cae7cc9e9..000000000
--- a/.env.build.example
+++ /dev/null
@@ -1,34 +0,0 @@
-# XXX Copy this file as ".env.build", will be used only in development stage. For staging/production stages, check the config at "now.CUSTOMER_REF.APP_STAGE.yml".
-# XXX Used during the build step in next.config.js, all env variables here must be bundled in ./next.config.js for the project to work locally
-# See https://zeit.co/docs/v2/environment-variables-and-secrets#local-development
-
-# The environment is "where" the application is running. It can be either "development" (localhost) or "production" (on Zeit's servers)
-NODE_ENV=development
-
-# The stage is "how" the application is running. It can be either "development" (localhost), "staging" or "production" (on Zeit's servers)
-APP_STAGE=development
-
-# The NRN preset used for the demo
-NRN_PRESET=v1-ssr-mst-aptd-gcms-lcz-sty
-
-# The customer that is being deployed
-CUSTOMER_REF=customer1
-
-# Locize project ID, can be found in the project settings page (valid value)
-LOCIZE_PROJECT_ID=658fc999-dfa8-4307-b9d7-b4870ad5b968
-
-# Locize API key, used to automatically save missing translations - Example: 615384ff-0f39-4c7b-89ca-9b0acbfd0869 (fake value)
-# TODO Create a Locize account at https://www.locize.app/?ref=unly-nrn
-LOCIZE_API_KEY=
-
-# Amplitude API key, used to report analytics usage - Example: 615384ff0f394c7b89ca9b0acbfd0869 (fake value)
-AMPLITUDE_API_KEY=
-
-# Sentry DSN, used to report events (errors, etc.) - Example: https://14fa1cae05079675b18cd05403ae5c48@sentry.io/1234567 (fake value)
-SENTRY_DSN=
-
-# GraphQL API endpoint - XXX We only use one stage due to our plan's limitation, but using two stages is the recommended way (valid value)
-GRAPHQL_API_ENDPOINT=https://api-euwest.graphcms.com/v1/ck73ixhlv09yt01dv2ga1bkbp/master
-
-# GraphQL API key (GraphCMS auth token) - Readonly because we want everyone to fetch data but not mess with it (valid value)
-GRAPHQL_API_KEY=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImdjbXMtbWFpbi1wcm9kdWN0aW9uIn0.eyJ2ZXJzaW9uIjoyLCJ0b2tlbklkIjoiMGEyZWYwZWYtYmY4Ny00ZmVlLTkzYmQtMzBkMzIzYzAwODM4IiwiaWF0IjoxNTgyNzQ2MDc1LCJpc3MiOiJodHRwczovL21hbmFnZW1lbnQuZ3JhcGhjbXMuY29tLyJ9.wE-IDwFnCNpwUj5AMeGsbf2CGcTgkT1KkE9_l6s-bU03sckdDgV2LXCHH5Fb8XwJGAkoeD5bK9yDnY6oNapiusvg4xRqY-hkRxgh8lB_lTXXpIka6HhjIJz1RQO_dObNGFqr41dKihseBqN5Ce4AJvQBIHyJasKX63xe2eEzdUnWfuNUmrG_XStRu1-xDKKp7vFVox272rr-LqgwRymF3eYcw7J-IAag4qpztoU6zNhKJNYzQKdMMejvMDNg34bHNp4TRIbIUIYpytkwCaAb5TsH98xEiFziU5rUr4EYToSltgE46VnqX2npm56qK-AGp5zauMZgvA20Djtb7BuYqGAqCdOHEQtjjyVLufCu6Y72i9gNQqFQ-WGQ6AFN84KT7BJgRoTruduYG9VhGMOR59HR3jG2QIWXOCt55aI9YwAGNQii0b_QqaoSO08Pb_Ooji5abFLISs70jQb-z1QcnvHIzHnsKqymEWwZhbkxpwf8bv8C6-8k4JGB5YdVj3T_0XQ-OCyvWQIGwVxKysLj8HBeVvXOKUyz7p-thOHbO4qSaRaV7w6_Yy2XtdwBlkIiTTqLezN34vCnsyhZ7N1IgLzp0bwNCyCoPOFs5Q9Ccw7hwJRP3kDT2cW4COJWVt-V5YF_9nnlZN8JjcIgv7FMZKoKRHi004vSosPYGd-v3Uw
diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 000000000..3b32667a0
--- /dev/null
+++ b/.env.local.example
@@ -0,0 +1,40 @@
+# XXX Template example
+# This file is an example, meant to be duplicated as ".env.local" for local override
+
+# See https://nextjs.org/docs/basic-features/environment-variables
+
+# XXX Tips: How is this file meant to be used?
+# This file is NOT tracked by git and can contain sensitive information, or override variables from ".env".
+
+# XXX Tips: When is this file being used?
+# This file is used only when building the Next.js app locally (localhost), whether it's for running `next dev` or `next build`.
+# For staging/production stages, the app relies on "vercel.{NEXT_PUBLIC_CUSTOMER_REF}.{NEXT_PUBLIC_APP_STAGE}.yml:build.env".
+
+# XXX Tips: What's the difference between env vars starting with "NEXT_PUBLIC_" and the others?
+# All env variables that DON'T start with "NEXT_PUBLIC_" MUST be manually exposed by ./next.config.js for the project to work locally
+# "NEXT_PUBLIC_" has a semantic purpose. If you mean to use a variable on the browser, then you should use "NEXT_PUBLIC_".
+# Any non-sensitive env variable should start with "NEXT_PUBLIC_".
+# Sensitive information MUST NOT start with "NEXT_PUBLIC_".
+# You must be careful to use sensitive information only on the server-side, because if you use them on the browser or getInitialProps, they'll be leaked, even if the variable doesn't start with "NEXT_PUBLIC_".
+# Any change to this file needs a server restart to be applied.
+
+# Locize API key, can be found under "Your project > Settings > Api Keys" at https://www.locize.app/?ref=unly-nrn
+# Used to automatically save missing translations when working locally
+# Optional - If not set, the app will work anyway, it just won't create new keys automatically
+# Example (fake value): 615384ff-0f39-4c7b-89ca-9b0acbfd0869
+LOCIZE_API_KEY=
+
+# Sentry DSN, can be found under "Your project > Client Keys (DSN)" at https://sentry.io/settings/YOUR_ORG/projects/YOUR_PROJECT/keys/
+# Used to send monitoring events (errors, etc.)
+# Optional - If not set, the app will work anyway, it just won't send any event
+# Example (fake value): https://14fa1cae05079675b18cd05403ae5c48@sentry.io/1234567
+SENTRY_DSN=
+
+# Github "personal access token", can be generated at "Settings > Developer settings > Personal access tokens" at https://github.com/settings/tokens
+# Used by "Workflow Dispatch" GitHub Actions
+# Needs the following scopes:
+# - Repo (FULL)
+# - Workflow
+# Optional - If not set, the app will work anyway, it just won't be able to deploy new instances through the "startVercelDeployment" API
+# Example (fake value): 278c560c1314b8b032c1314b856072bdaaaaaaaa
+GITHUB_DISPATCH_TOKEN=
diff --git a/.eslintignore b/.eslintignore
index b955f23b7..4bd531986 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,8 +1,10 @@
node_modules
.next
+cypress
src/**/*.test*
src/gql/**
src/propTypes/**
src/svg/**
src/types/**
src/components/svg/**
+src/stories/sb-examples/**
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 7b7e8a4da..c5fa7d697 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -6,15 +6,15 @@ env:
node: true
extends:
- plugin:@typescript-eslint/recommended
- - plugin:react/recommended
- plugin:jsx-a11y/recommended
+ - next # Recommended to be added last after all other "recommended" tools - See https://nextjs.org/docs/basic-features/eslint#disabling-rules
+ - next/core-web-vitals # Enforce stronger rules for Core Web Vitals - See https://nextjs.org/docs/basic-features/eslint#core-web-vitals
globals:
Atomics: readonly
SharedArrayBuffer: readonly
plugins:
- jest
- - react
- - react-hooks
+ - '@emotion'
parser: '@typescript-eslint/parser'
parserOptions:
project: ./tsconfig.json
@@ -44,9 +44,7 @@ rules: # See https://eslint.org/docs/rules
strict: 'off'
no-console: 1 # Shouldn't use "console", but "logger" instead
allowArrowFunctions: 0
- no-unused-vars:
- - warn # Warn otherwise it false-positive with needed React imports
- - args: none # Allow to declare unused variables in function arguments, meant to be used later
+ no-unused-vars: 0 # Disabled, already handled by @typescript-eslint/no-unused-vars
import/prefer-default-export: 0 # When there is only a single export from a module, don't enforce a default export, but rather let developer choose what's best
no-else-return: 0 # Don't enforce, let developer choose. Sometimes we like to specifically use "return" for the sake of comprehensibility and avoid ambiguity
no-underscore-dangle: 0 # Allow _ before/after variables and functions, convention for something meant to be "private"
@@ -78,12 +76,12 @@ rules: # See https://eslint.org/docs/rules
react-hooks/exhaustive-deps: warn
react/jsx-no-target-blank: warn # Not using "noreferrer" is not a security risk, but "noopener" should always be used indeed
react/prop-types: warn # Should be handled with TS instead
- react/no-unescaped-entities: warn # Causes text mismatch when enabled
+ react/no-unescaped-entities: 0 # Causes text mismatch when enabled
jsx-a11y/anchor-is-valid: warn
linebreak-style:
- error
- unix
- '@typescript-eslint/ban-ts-ignore': warn # ts-ignore are sometimes the only way to bypass a TS issue, we trust we will use them for good and not abuse them
+ '@typescript-eslint/ban-ts-comment': warn # ts-ignore are sometimes the only way to bypass a TS issue, we trust we will use them for good and not abuse them
'@typescript-eslint/no-use-before-define': warn
'@typescript-eslint/no-unused-vars':
- warn
@@ -91,6 +89,19 @@ rules: # See https://eslint.org/docs/rules
vars: 'all' # We don't want unused variables (noise) - XXX Note that this will be a duplicate of "no-unused-vars" rule
args: 'none' # Sometimes it's useful to have unused arguments for later use, such as describing what args are available (DX)
ignoreRestSiblings: true # Sometimes it's useful to have unused props for later use, such as describing what props are available (DX)
+ varsIgnorePattern: 'React|logger|rest'
+ '@typescript-eslint/ban-types':
+ - error
+ -
+ extendDefaults: true
+ types:
+ '{}': false # Allow writing `type Props = {}` - See https://github.com/typescript-eslint/typescript-eslint/issues/2063#issuecomment-632833366
+ '@typescript-eslint/no-explicit-any': off
+
+ # Emotion rules - See https://github.com/emotion-js/emotion/tree/master/packages/eslint-plugin
+ # Emotion 11 - See https://emotion.sh/docs/emotion-11
+ '@emotion/pkg-renaming': error
+
overrides:
- files: ['**/*.tsx']
rules:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..ee1e475f8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,33 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Screenshots**
+If applicable, add screenshots to help explain your problem.
+
+**Desktop (please complete the following information):**
+ - OS: [e.g. iOS]
+
+**Smartphone (please complete the following information):**
+ - OS: [e.g. iOS8.1]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..bbcbbe7d6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,20 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+Add any other context or screenshots about the feature request here.
diff --git a/.github/ISSUE_TEMPLATE/having-a-question-.md b/.github/ISSUE_TEMPLATE/having-a-question-.md
new file mode 100644
index 000000000..17614ba74
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/having-a-question-.md
@@ -0,0 +1,10 @@
+---
+name: Having a question?
+about: Use the community Discussions
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+Please ask questions in the [Discussions](https://github.com/UnlyEd/next-right-now/discussions)! :)
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
new file mode 100644
index 000000000..4fc774b3f
--- /dev/null
+++ b/.github/workflows/README.md
@@ -0,0 +1 @@
+Learn more about [our CI/CD configuration](https://unlyed.github.io/next-right-now/guides/ci-cd/)
diff --git a/.github/workflows/auto-git-release.yml b/.github/workflows/auto-git-release.yml
new file mode 100644
index 000000000..9cd571c3c
--- /dev/null
+++ b/.github/workflows/auto-git-release.yml
@@ -0,0 +1,69 @@
+# Summary:
+# Automatically tag and release when changes land on any branch.
+# Tag and release changes on the master branch, as releases. (one release per commit)
+# Tag and pre-release changes on the other branches, as pre-releases with a "x" as "patch" indicator.
+# (one release per branch, the release is updated at every push)
+#
+# Dependencies overview:
+# - See https://github.com/PaulHatch/semantic-version https://github.com/PaulHatch/semantic-version/tree/v3.2
+# - See https://github.com/marvinpinto/action-automatic-releases https://github.com/marvinpinto/action-automatic-releases/tree/v1.1.1
+# - See https://github.com/rlespinasse/github-slug-action https://github.com/rlespinasse/github-slug-action/tree/3.x
+
+name: 'Auto release'
+on:
+ push:
+ branches:
+ - '*'
+
+jobs:
+ tag-and-release:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - name: Expose GitHub slug/short variables # See https://github.com/rlespinasse/github-slug-action#exposed-github-environment-variables
+ uses: rlespinasse/github-slug-action@v3.x # See https://github.com/rlespinasse/github-slug-action
+ - uses: actions/checkout@v2 # See https://github.com/actions/checkout
+ with:
+ fetch-depth: 0 # See https://github.com/PaulHatch/semantic-version#important-note-regarding-the-checkout-action
+ - uses: paulhatch/semantic-version@v3.2 # See https://github.com/PaulHatch/semantic-version
+ id: next_semantic_version # Output: https://github.com/PaulHatch/semantic-version/blob/master/index.js#L63-L69
+ with: # See https://github.com/PaulHatch/semantic-version#usage
+ branch: ${{ env.GITHUB_REF_SLUG }} # Use current branch
+ tag_prefix: "v" # The prefix to use to identify tags
+ major_pattern: "(MAJOR)" # A string which, if present in a git commit, indicates that a change represents a major (breaking) change
+ minor_pattern: "(MINOR)" # Same as above except indicating a minor change
+ format: "${major}.${minor}.${patch}-${{ env.GITHUB_REF_SLUG }}" # A string to determine the format of the version output
+ short_tags: false # If set to false, short tags like "v1" will not be considered, only full versions tags such as "v1.0.0" will be used to determine the version.
+ bump_each_commit: true # If this input is set to true, every commit will be treated as a new version, bumping the patch, minor, or major version based on the commit message.
+ - run: |
+ echo "Branch name: ${{ env.GITHUB_REF_SLUG }}"
+ echo ${{ join(steps.next_semantic_version.outputs.*, ' - ') }}
+ echo "Next version: ${{ steps.next_semantic_version.outputs.version }}"
+ echo "Next version tag: ${{ steps.next_semantic_version.outputs.version_tag }}"
+
+ # Auto-release when a commit hit "master"
+ - uses: marvinpinto/action-automatic-releases@2aa16da5b7423a298a2d56cf5d9c2dafcef5f542 # Pin "latest" https://github.com/marvinpinto/action-automatic-releases/commit/2aa16da5b7423a298a2d56cf5d9c2dafcef5f542 necessary to avoid "The `set-env` command is disabled."
+ if: startsWith(env.GITHUB_REF_SLUG, 'v2-') || env.GITHUB_REF_SLUG == 'master' || env.GITHUB_REF_SLUG == 'main' # If master, mark release as official release
+ with: # See https://github.com/marvinpinto/action-automatic-releases/tree/v1.1.1#supported-parameters
+ repo_token: "${{ secrets.GITHUB_TOKEN }}"
+ automatic_release_tag: "v${{ steps.next_semantic_version.outputs.version }}"
+ prerelease: false
+ title: "Automatic release ${{ steps.next_semantic_version.outputs.version_tag }} (from `${{ env.GITHUB_REF_SLUG }}`)"
+ files: |
+ README.md
+ CHANGELOG.md
+ LICENSE
+
+ # Auto-release (as pre-release) in a shared tag (using the branch's name) when a commit hits another branch
+ - uses: marvinpinto/action-automatic-releases@2aa16da5b7423a298a2d56cf5d9c2dafcef5f542 # Pin "latest" https://github.com/marvinpinto/action-automatic-releases/commit/2aa16da5b7423a298a2d56cf5d9c2dafcef5f542 necessary to avoid "The `set-env` command is disabled."
+ # When using "!", must use expression syntax - See https://github.community/t/if-not-startswith-mutually-exclusive-steps/141841/2?u=vadorequest
+ if: ${{ !startsWith(env.GITHUB_REF_SLUG, 'v2-') && env.GITHUB_REF_SLUG != 'master' && env.GITHUB_REF_SLUG != 'main' }} # If not master, mark release as pre-release and include branch name to avoid tagging conflicts
+ with: # See https://github.com/marvinpinto/action-automatic-releases/tree/v1.1.1#supported-parameters
+ repo_token: "${{ secrets.GITHUB_TOKEN }}"
+ automatic_release_tag: "v${{ steps.next_semantic_version.outputs.major }}.${{ steps.next_semantic_version.outputs.minor }}.x-rc-${{ env.GITHUB_REF_SLUG }}"
+ prerelease: true
+ title: "Automatic pre-release `v${{ steps.next_semantic_version.outputs.major }}.${{ steps.next_semantic_version.outputs.minor }}.x-rc-${{ env.GITHUB_REF_SLUG }}`"
+ files: |
+ README.md
+ CHANGELOG.md
+ LICENSE
diff --git a/.github/workflows/deploy-vercel-production.yml b/.github/workflows/deploy-vercel-production.yml
new file mode 100644
index 000000000..1b1d42aa8
--- /dev/null
+++ b/.github/workflows/deploy-vercel-production.yml
@@ -0,0 +1,367 @@
+# Summary:
+# Creates a new production deployment on Vercel's platform, when anything is pushed in the "master" branch.
+# Once the deployment has been performed by Vercel, we wait for its status to reach the "READY" state before doing anything else.defaults:
+# - Once ready, an HTTP call is sent to the webhook that is configured within the customer config file (VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK). (optional)
+# - Once ready, E2E tests are executed on the newly deployed domain (Cypress).
+# - Once ready, quality tests are executed on the newly deployed domain (LightHouse).
+#
+# This workflow can also be triggered using "Workflow Dispatch".
+# See https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch
+# See https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows#workflow_dispatch
+# This workflow can also be triggered using the API (uses Workflow Dispatch in the background).
+# See src/pages/api/startVercelDeployment.ts
+#
+# LEARN MORE AT https://unlyed.github.io/next-right-now/guides/ci-cd/
+#
+# Dependencies overview:
+# - See https://github.com/actions/setup-node https://github.com/actions/setup-node/tree/v2
+# - See https://github.com/actions/checkout https://github.com/actions/checkout/tree/v1
+# - See https://github.com/actions/upload-artifact https://github.com/actions/upload-artifact/tree/v1
+# - See https://github.com/rlespinasse/github-slug-action https://github.com/rlespinasse/github-slug-action/tree/3.x
+# - See https://github.com/jwalton/gh-find-current-pr https://github.com/jwalton/gh-find-current-pr/tree/v1
+# - See https://github.com/peter-evans/create-or-update-comment https://github.com/peter-evans/create-or-update-comment/tree/v1
+# - See https://github.com/UnlyEd/github-action-await-vercel https://github.com/UnlyEd/github-action-await-vercel/tree/v1.1.1
+# - See https://github.com/UnlyEd/github-action-store-variable https://github.com/UnlyEd/github-action-store-variable/tree/v2.1.1
+# - See https://github.com/cypress-io/github-action https://github.com/cypress-io/github-action/tree/v2
+# - See https://github.com/foo-software/lighthouse-check-action https://github.com/foo-software/lighthouse-check-action/tree/v1.0.1
+# - See https://github.com/bobheadxi/deployments https://github.com/bobheadxi/deployments/tree/v0.4.3
+
+name: Deploy to Vercel (production)
+
+on:
+ # There are several ways to trigger Github actions - See https://help.github.com/en/actions/reference/events-that-trigger-workflows#example-using-a-single-event for a comprehensive list:
+ # - "push": Triggers each time a commit is pushed
+ # - "pull_request": Triggers each time a commit is pushed within a pull request, it makes it much easier to write comments within the PR, but it suffers some strong limitations:
+ # - There is no way to trigger when a PR is merged into another - See https://github.community/t/pull-request-action-does-not-run-on-merge/16092?u=vadorequest
+ # - It won't trigger when the PR is conflicting with its base branch - See https://github.community/t/run-actions-on-pull-requests-with-merge-conflicts/17104/2?u=vadorequest
+ push: # Triggers on each pushed commit
+ branches:
+ - 'master'
+ - 'main'
+ - 'v2-mst-aptd-gcms-lcz-sty' # XXX Name of the NRN preset branch, acting as a main branch (all commits pushed to the preset branch are deployed to production)
+
+ # Runs the deployment workflow only when there are changes made to specific files, in any of the below paths
+ # Optimizes our CI/CD by not deploying the Next.js site needlessly (faster deploy, lower cost)
+ # XXX Filter pattern cheat sheet https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
+ paths:
+ - '.github/workflows/deploy-vercel-production.yml'
+ - 'cypress/**'
+ - '!cypress/_examples/**'
+ - '!cypress/integration-storybook/**'
+ - '!cypress/config-*.json' # Exclude all config files
+ - 'cypress/config-customer-ci-cd.json' # Force include CI/CD config file
+ - 'public/**'
+ - 'scripts/populate-git-env.sh'
+ - 'src/**'
+ - '!src/stories/**'
+ - '.eslint*'
+ - '*.js*' # Includes all .js/.json at the root level
+ - '*.ts' # Includes all .ts at the root level
+ - '.*ignore' # Includes .gitignore and .vercelignore
+ - 'yarn.lock'
+ - '!**/*.md' # Exclude all markdown files
+
+ # Allow manual trigger via a button in github or a HTTP call - See https://docs.github.com/en/actions/configuring-and-managing-workflows/configuring-a-workflow#manually-running-a-workflow
+ # XXX See https://unlyed.github.io/next-right-now/guides/ci-cd/gha-deploy-vercel#triggering-the-action-remotely-using-workflow_dispatch
+ workflow_dispatch:
+ inputs:
+ customer:
+ description: 'Customer to deploy'
+ required: true
+
+env:
+ STAGE: production
+
+jobs:
+ # Configures the deployment environment, install dependencies (like node, npm, etc.) that are requirements for the upcoming jobs
+ # Ex: Necessary to run `yarn deploy`
+ setup-environment:
+ name: Setup deployment environment (Ubuntu latest - Node 14.x)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Installing node.js
+ uses: actions/setup-node@v2 # Used to install node environment - https://github.com/actions/setup-node
+ with:
+ node-version: '14.x' # Use the same node.js version as the one Vercel's uses (currently node14.x)
+
+ # Starts a Vercel deployment, using the production configuration file of the default customer
+ # The default customer is the one defined in the `vercel.json` file (which is a symlink to the actual file)
+ # N.B: It's Vercel that will perform the actual deployment
+ start-production-deployment:
+ name: Starts Vercel deployment (production) (Ubuntu latest)
+ runs-on: ubuntu-latest
+ needs: setup-environment
+ timeout-minutes: 40 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # Resolves customer to deploy from github event input (falls back to resolving it from vercel.json file)
+ - name: Resolve customer to deploy
+ run: |
+ # Resolving customer to deploy based on the github event input, when using manual deployment triggerer through "workflow_dispatch" event
+ # Falls back to the customer specified in the vercel.json file, which is most useful when deployment is triggered through "push" event
+ MANUAL_TRIGGER_CUSTOMER="${{ github.event.inputs.customer}}"
+ echo "MANUAL_TRIGGER_CUSTOMER: " $MANUAL_TRIGGER_CUSTOMER
+ echo "MANUAL_TRIGGER_CUSTOMER=$MANUAL_TRIGGER_CUSTOMER" >> $GITHUB_ENV
+
+ CUSTOMER_REF_TO_DEPLOY="${MANUAL_TRIGGER_CUSTOMER:-$(cat vercel.json | jq --raw-output '.build.env.NEXT_PUBLIC_CUSTOMER_REF')}"
+ echo "Customer to deploy: " $CUSTOMER_REF_TO_DEPLOY
+ echo "CUSTOMER_REF_TO_DEPLOY=$CUSTOMER_REF_TO_DEPLOY" >> $GITHUB_ENV
+
+ # Create a GitHub deployment (within a GitHub environment), useful to keep a public track of all deployments directly in GitHub
+ - name: Start GitHub deployment
+ uses: bobheadxi/deployments@v0.4.3 # See https://github.com/bobheadxi/deployments
+ id: start-github-deployment
+ with:
+ step: start
+ token: ${{ secrets.GITHUB_TOKEN }}
+ env: ${{ env.CUSTOMER_REF_TO_DEPLOY }}-${{ env.STAGE }} # Uses "$customer-$env" as GitHub environment name, because it's easier this way to see what has changed per customer, per stage
+ no_override: true # Disables auto marking previous environments as "inactive", as they're still active (Vercel deployments don't auto-deactivate) and it would remove the previous deployment links needlessly
+
+ - name: Deploying on Vercel (${{ env.STAGE }})
+ uses: UnlyEd/github-action-deploy-on-vercel@94d41ec1ff9b5b1de5256312e385632b6fcd8fa4 # Pin "v1.2.1" - See https://github.com/UnlyEd/github-action-deploy-on-vercel/commit/94d41ec1ff9b5b1de5256312e385632b6fcd8fa4
+ with:
+ command: "yarn deploy:ci:gha:production --token ${{ secrets.VERCEL_TOKEN }}"
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} # Passing github's secret to the worker
+ GIT_COMMIT_REF: ${{ github.ref }} # Passing current branch/tag to the worker
+ GIT_COMMIT_SHA: ${{ github.ref }} # Passing current commit SHA to the worker
+ CUSTOMER_REF: ${{ env.CUSTOMER_REF_TO_DEPLOY }} # Passing current customer to deploy
+
+ # Update the previously created GitHub deployment, and link it to our Vercel deployment
+ - name: Link GitHub deployment to Vercel
+ uses: bobheadxi/deployments@v0.4.3 # See https://github.com/bobheadxi/deployments
+ id: link-github-deployment-to-vercel
+ if: always()
+ with:
+ step: finish
+ token: ${{ secrets.GITHUB_TOKEN }}
+ status: ${{ job.status }}
+ deployment_id: ${{ steps.start-github-deployment.outputs.deployment_id }}
+ env_url: ${{ env.VERCEL_DEPLOYMENT_URL }} # Link the Vercel deployment url to the GitHub environment
+
+ # At the end of the job, store all variables we will need in the following jobs
+ # The variables will be stored in and retrieved from a GitHub Artifact (each variable is stored in a different file)
+ - name: Store variables for next jobs
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ with:
+ variables: |
+ CUSTOMER_REF_TO_DEPLOY=${{ env.CUSTOMER_REF_TO_DEPLOY }}
+ VERCEL_DEPLOYMENT_URL=${{ env.VERCEL_DEPLOYMENT_URL }}
+ VERCEL_DEPLOYMENT_DOMAIN=${{ env.VERCEL_DEPLOYMENT_DOMAIN }}
+ MANUAL_TRIGGER_CUSTOMER=${{ env.MANUAL_TRIGGER_CUSTOMER }}
+ GITHUB_PULL_REQUEST_ID=${{ steps.pr_id_finder.outputs.number }}
+
+ # Waits for the Vercel deployment to reach "READY" state, so that other actions will be applied on a domain that is really online
+ await-for-vercel-deployment:
+ name: Await current deployment to be ready (Ubuntu latest)
+ runs-on: ubuntu-latest
+ needs: start-production-deployment
+ timeout-minutes: 5 # Limit current job timeout (including action timeout setup down there) https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: |
+ VERCEL_DEPLOYMENT_DOMAIN
+
+ # Wait for deployment to be ready, before running E2E (otherwise Cypress might start testing too early, and gets redirected to Vercel's "Login page", and tests fail)
+ - name: Awaiting Vercel deployment to be ready
+ uses: UnlyEd/github-action-await-vercel@v1.2.14 # See https://github.com/UnlyEd/github-action-await-vercel
+ id: await-vercel
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ with:
+ deployment-url: ${{ env.VERCEL_DEPLOYMENT_DOMAIN }} # Must only contain the domain name (no http prefix, etc.)
+ timeout: 90 # Wait for 90 seconds before failing
+
+ - name: Display deployment status
+ run: "echo The deployment is ${{ env.readyState }}"
+
+ # Send a HTTP call to the webhook url that's provided in the customer configuration file (vercel.*.json)
+ send-webhook-callback-once-deployment-ready:
+ name: Invoke webhook callback url defined by the customer (Ubuntu latest)
+ runs-on: ubuntu-latest
+ needs: await-for-vercel-deployment
+ timeout-minutes: 5 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+ - name: Expose GitHub slug/short variables # See https://github.com/rlespinasse/github-slug-action#exposed-github-environment-variables
+ uses: rlespinasse/github-slug-action@v3.x # See https://github.com/rlespinasse/github-slug-action
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: |
+ MANUAL_TRIGGER_CUSTOMER
+ CUSTOMER_REF_TO_DEPLOY
+
+ - name: Expose git environment variables and call webhook (if provided)
+ # Workflow overview:
+ # - Resolves webhook url from customer config file
+ # - If a webhook url was defined in the customer config file, send an HTTP request, as POST request, with a JSON request body dynamically generated
+ # - Prints the headers of the POST HTTP request (curl)
+ # TODO Convert this into an external bash script if possible, to simplify this step as much as possible
+ run: |
+ VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK=$(cat vercel.$CUSTOMER_REF_TO_DEPLOY.${{ env.STAGE }}.json | jq --raw-output '.build.env.VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK')
+ echo "Vercel deployment webhook url: " $VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK
+
+ # Checking if a webhook url is defined
+ if [ -n "$VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK" ]; then
+ # Run script that populates git-related variables as ENV variables
+ echo "Running script generate-post-data"
+ . ./scripts/generate-post-data.sh
+
+ echo "Print generate_post_data():"
+ echo "$(generate_post_data)"
+
+ echo "Calling webhook at '$VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK'"
+ echo "Sending HTTP request (curl):"
+ curl POST \
+ "$VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK" \
+ -vs \
+ --header "Accept: application/json" \
+ --header "Content-type: application/json" \
+ --data "$(generate_post_data)" \
+ 2>&1 | sed '/^* /d; /bytes data]$/d; s/> //; s/< //'
+
+ # XXX See https://stackoverflow.com/a/54225157/2391795
+ # -vs - add headers (-v) but remove progress bar (-s)
+ # 2>&1 - combine stdout and stderr into single stdout
+ # sed - edit response produced by curl using the commands below
+ # /^* /d - remove lines starting with '* ' (technical info)
+ # /bytes data]$/d - remove lines ending with 'bytes data]' (technical info)
+ # s/> // - remove '> ' prefix
+ # s/< // - remove '< ' prefix
+
+ else
+ echo "No webhook url defined in 'vercel.$CUSTOMER_REF_TO_DEPLOY.${{ env.STAGE }}.json:.build.env.VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK' (found '$VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK')"
+ fi
+ env:
+ GIT_COMMIT_REF: ${{ github.ref }} # Passing current branch/tag to the worker
+ GIT_COMMIT_SHA: ${{ github.sha }} # Passing current commit SHA to the worker
+ # Passing exposed GitHub environment variables - See https://github.com/rlespinasse/github-slug-action#exposed-github-environment-variables
+ DEPLOYMENT_STAGE: ${{ env.STAGE }}
+ GITHUB_REF_SLUG: ${{ env.GITHUB_REF_SLUG }}
+ GITHUB_HEAD_REF_SLUG: ${{ env.GITHUB_HEAD_REF_SLUG }}
+ GITHUB_BASE_REF_SLUG: ${{ env.GITHUB_BASE_REF_SLUG }}
+ GITHUB_EVENT_REF_SLUG: ${{ env.GITHUB_EVENT_REF_SLUG }}
+ GITHUB_REPOSITORY_SLUG: ${{ env.GITHUB_REPOSITORY_SLUG }}
+ GITHUB_REF_SLUG_URL: ${{ env.GITHUB_REF_SLUG_URL }}
+ GITHUB_HEAD_REF_SLUG_URL: ${{ env.GITHUB_HEAD_REF_SLUG_URL }}
+ GITHUB_BASE_REF_SLUG_URL: ${{ env.GITHUB_BASE_REF_SLUG_URL }}
+ GITHUB_EVENT_REF_SLUG_URL: ${{ env.GITHUB_EVENT_REF_SLUG_URL }}
+ GITHUB_REPOSITORY_SLUG_URL: ${{ env.GITHUB_REPOSITORY_SLUG_URL }}
+ GITHUB_SHA_SHORT: ${{ env.GITHUB_SHA_SHORT }}
+
+ # Runs E2E tests against the Vercel deployment
+ run-2e2-tests:
+ name: Run end to end (E2E) tests (Ubuntu latest)
+ runs-on: ubuntu-latest
+ # Docker image with Cypress pre-installed
+ # https://github.com/cypress-io/cypress-docker-images/tree/master/included
+ container: cypress/included:7.4.0
+ needs: await-for-vercel-deployment
+ timeout-minutes: 20 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: |
+ VERCEL_DEPLOYMENT_URL
+
+ # Runs the E2E tests against the new Vercel deployment
+ - name: Run E2E tests (Cypress)
+ uses: cypress-io/github-action@v2 # See https://github.com/cypress-io/github-action
+ with:
+ # XXX We disabled "wait-on" option, because it's useless. Cypress will fail anyway, because it gets redirected to some internal Vercel URL if the domain isn't yet available - See https://github.com/cypress-io/github-action/issues/270
+ # wait-on: '${{ env.VERCEL_DEPLOYMENT_URL }}' # Be sure that the endpoint is ready by pinging it before starting tests, using a default timeout of 60 seconds
+ config-file: 'cypress/config-customer-ci-cd.json' # Use Cypress config file for CI/CD, and override it below
+ config: baseUrl=${{ env.VERCEL_DEPLOYMENT_URL }} # Overriding baseUrl provided by config file to test the new deployment
+ env:
+ # Enables Cypress debugging logs, very useful if Cypress crashes, like out-of-memory issues.
+ # DEBUG: "cypress:*" # Enable all logs. See https://docs.cypress.io/guides/references/troubleshooting.html#Print-DEBUG-logs
+ DEBUG: "cypress:server:util:process_profiler" # Enable logs for "memory and CPU usage". See https://docs.cypress.io/guides/references/troubleshooting.html#Log-memory-and-CPU-usage
+
+ # On E2E failure, upload screenshots
+ - name: Upload screenshots artifacts (E2E failure)
+ uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
+ if: failure()
+ with:
+ name: screenshots
+ path: cypress/screenshots/
+
+ # On E2E failure, upload videos
+ - name: Upload videos artifacts (E2E failure)
+ uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
+ if: failure()
+ with:
+ name: videos
+ path: cypress/videos/
+
+ # Runs LightHouse reports in parallel of E2E tests
+ run-lighthouse-tests:
+ name: Run LightHouse checks (Ubuntu latest)
+ runs-on: ubuntu-latest
+ needs: await-for-vercel-deployment
+ timeout-minutes: 5 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # To store reports and then upload them, we must create the folder beforehand
+ - name: Create temporary folder for artifacts storage
+ run: mkdir /tmp/lighthouse-artifacts
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: VERCEL_DEPLOYMENT_URL
+
+ # Runs LightHouse for a given url and create HTML reports in the specified directory (outputDirectory)
+ # Action documentation: https://github.com/marketplace/actions/lighthouse-check#usage-standard-example
+ - name: Run Lighthouse
+ uses: foo-software/lighthouse-check-action@v2.0.5
+ id: lighthouseCheck
+ with: # See https://github.com/marketplace/actions/lighthouse-check#inputs for all options
+ # XXX We don't enable comments, because there is no branch to write them into
+ outputDirectory: /tmp/lighthouse-artifacts # Used to upload artifacts.
+ emulatedFormFactor: all # Run LightHouse against "mobile", "desktop", or "all" devices
+ urls: ${{ env.VERCEL_DEPLOYMENT_URL }}, ${{ env.VERCEL_DEPLOYMENT_URL }}/en, ${{ env.VERCEL_DEPLOYMENT_URL }}/fr
+ locale: en
+
+ # Upload HTML report created by lighthouse as an artifact.
+ # XXX Disable this if you don't use them, as they are a bit heavy (~3MB) and might cost you money, if you're using a private repository
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v1
+ with:
+ name: Lighthouse reports
+ path: /tmp/lighthouse-artifacts
+
+ # Using a pre-build action to make the action fail if your score is too low. It can be really interesting to track a low score on a commit
+ # You can remove this action IF you don't want lighthouse to be a blocking point in your CI
+ # Official documentation: https://github.com/foo-software/lighthouse-check-status-action
+ - name: Handle Lighthouse Check results
+ uses: foo-software/lighthouse-check-status-action@v1.0.1 # See https://github.com/foo-software/lighthouse-check-action
+ with:
+ lighthouseCheckResults: ${{ steps.lighthouseCheck.outputs.lighthouseCheckResults }}
+ minAccessibilityScore: "50"
+ minBestPracticesScore: "50"
+ minPerformanceScore: "30"
+ minProgressiveWebAppScore: "50"
+ minSeoScore: "50"
diff --git a/.github/workflows/deploy-vercel-staging.yml b/.github/workflows/deploy-vercel-staging.yml
new file mode 100644
index 000000000..27769da4e
--- /dev/null
+++ b/.github/workflows/deploy-vercel-staging.yml
@@ -0,0 +1,442 @@
+# Summary:
+# Creates a new preview deployment on Vercel's platform, when anything is pushed in any branch (except for the "master" branch).
+# Once the deployment has been performed by Vercel, we wait for its status to reach the "READY" state before doing anything else.defaults:
+# - Once ready, an HTTP call is sent to the webhook that is configured within the customer config file (VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK). (optional)
+# - Once ready, E2E tests are executed on the newly deployed domain (Cypress).
+# - Once ready, quality tests are executed on the newly deployed domain (LightHouse).
+#
+# This workflow can also be triggered using "Workflow Dispatch".
+# See https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch
+# See https://docs.github.com/en/free-pro-team@latest/actions/reference/events-that-trigger-workflows#workflow_dispatch
+# This workflow can also be triggered using the API (uses Workflow Dispatch in the background).
+# See src/pages/api/startVercelDeployment.ts
+#
+# LEARN MORE AT https://unlyed.github.io/next-right-now/guides/ci-cd/
+#
+# Dependencies overview:
+# - See https://github.com/actions/setup-node https://github.com/actions/setup-node/tree/v2
+# - See https://github.com/actions/checkout https://github.com/actions/checkout/tree/v1
+# - See https://github.com/actions/upload-artifact https://github.com/actions/upload-artifact/tree/v1
+# - See https://github.com/rlespinasse/github-slug-action https://github.com/rlespinasse/github-slug-action/tree/3.x
+# - See https://github.com/jwalton/gh-find-current-pr https://github.com/jwalton/gh-find-current-pr/tree/v1
+# - See https://github.com/peter-evans/create-or-update-comment https://github.com/peter-evans/create-or-update-comment/tree/v1
+# - See https://github.com/UnlyEd/github-action-await-vercel https://github.com/UnlyEd/github-action-await-vercel/tree/v1.1.1
+# - See https://github.com/UnlyEd/github-action-store-variable https://github.com/UnlyEd/github-action-store-variable/tree/v2.1.1
+# - See https://github.com/cypress-io/github-action https://github.com/cypress-io/github-action/tree/v2
+# - See https://github.com/foo-software/lighthouse-check-action https://github.com/foo-software/lighthouse-check-action/tree/v1.0.1
+# - See https://github.com/bobheadxi/deployments https://github.com/bobheadxi/deployments/tree/v0.4.3
+
+name: Deploy to Vercel (staging)
+
+on:
+ # There are several ways to trigger Github actions - See https://help.github.com/en/actions/reference/events-that-trigger-workflows#example-using-a-single-event for a comprehensive list:
+ # - "push": Triggers each time a commit is pushed
+ # - "pull_request": Triggers each time a commit is pushed within a pull request, it makes it much easier to write comments within the PR, but it suffers some strong limitations:
+ # - There is no way to trigger when a PR is merged into another - See https://github.community/t/pull-request-action-does-not-run-on-merge/16092?u=vadorequest
+ # - It won't trigger when the PR is conflicting with its base branch - See https://github.community/t/run-actions-on-pull-requests-with-merge-conflicts/17104/2?u=vadorequest
+ push: # Triggers on each pushed commit
+ branches-ignore:
+ - 'master'
+ - 'main'
+ - 'v2-mst-aptd-gcms-lcz-sty' # XXX Name of the NRN preset branch, acting as a main branch (all commits pushed to the preset branch are deployed to production)
+
+ # Runs the deployment workflow only when there are changes made to specific files, in any of the below paths
+ # Optimizes our CI/CD by not deploying the Next.js site needlessly (faster deploy, lower cost)
+ # XXX Filter pattern cheat sheet https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
+ paths:
+ - '.github/workflows/deploy-vercel-staging.yml'
+ - 'cypress/**'
+ - '!cypress/_examples/**'
+ - '!cypress/integration-storybook/**'
+ - '!cypress/config-*.json' # Exclude all config files
+ - 'cypress/config-customer-ci-cd.json' # Force include CI/CD config file
+ - 'public/**'
+ - 'scripts/populate-git-env.sh'
+ - 'src/**'
+ - '!src/stories/**'
+ - '.eslint*'
+ - '*.js*' # Includes all .js/.json at the root level
+ - '*.ts' # Includes all .ts at the root level
+ - '.*ignore' # Includes .gitignore and .vercelignore
+ - 'yarn.lock'
+ - '!**/*.md' # Exclude all markdown files
+
+ # Allow manual trigger via a button in github or a HTTP call - See https://docs.github.com/en/actions/configuring-and-managing-workflows/configuring-a-workflow#manually-running-a-workflow
+ # XXX See https://unlyed.github.io/next-right-now/guides/ci-cd/gha-deploy-vercel#triggering-the-action-remotely-using-workflow_dispatch
+ workflow_dispatch:
+ inputs:
+ customer:
+ description: 'Customer to deploy'
+ required: true
+
+env:
+ STAGE: staging
+
+jobs:
+ # Configures the deployment environment, install dependencies (like node, npm, etc.) that are requirements for the upcoming jobs
+ # Ex: Necessary to run `yarn deploy`
+ setup-environment:
+ name: Setup deployment environment (Ubuntu latest - Node 14.x)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Installing node.js
+ uses: actions/setup-node@v2 # Used to install node environment - https://github.com/actions/setup-node
+ with:
+ node-version: '14.x' # Use the same node.js version as the one Vercel's uses (currently node14.x)
+
+ # Starts a Vercel deployment, using the staging configuration file of the default customer
+ # The default customer is the one defined in the `vercel.json` file (which is a symlink to the actual file)
+ # N.B: It's Vercel that will perform the actual deployment
+ start-staging-deployment:
+ name: Starts Vercel deployment (staging) (Ubuntu latest)
+ runs-on: ubuntu-latest
+ needs: setup-environment
+ timeout-minutes: 40 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ - name: Expose GitHub slug/short variables # See https://github.com/rlespinasse/github-slug-action#exposed-github-environment-variables
+ uses: rlespinasse/github-slug-action@v3.x # See https://github.com/rlespinasse/github-slug-action
+
+ # Resolves customer to deploy from github event input (falls back to resolving it from vercel.json file)
+ - name: Resolve customer to deploy
+ run: |
+ # Resolving customer to deploy based on the github event input, when using manual deployment triggerer through "workflow_dispatch" event
+ # Falls back to the customer specified in the vercel.json file, which is most useful when deployment is triggered through "push" event
+ MANUAL_TRIGGER_CUSTOMER="${{ github.event.inputs.customer}}"
+ echo "MANUAL_TRIGGER_CUSTOMER: " $MANUAL_TRIGGER_CUSTOMER
+ echo "MANUAL_TRIGGER_CUSTOMER=$MANUAL_TRIGGER_CUSTOMER" >> $GITHUB_ENV
+
+ CUSTOMER_REF_TO_DEPLOY="${MANUAL_TRIGGER_CUSTOMER:-$(cat vercel.json | jq --raw-output '.build.env.NEXT_PUBLIC_CUSTOMER_REF')}"
+ echo "Customer to deploy: " $CUSTOMER_REF_TO_DEPLOY
+ echo "CUSTOMER_REF_TO_DEPLOY=$CUSTOMER_REF_TO_DEPLOY" >> $GITHUB_ENV
+
+ # Build the alias domain based on our own rules (NRN own rules)
+ # Basically, if a branch starts with "v[0-9]" then we consider it to be a "preset branch" and we use the branch name as alias (e.g: "v2-mst-xxx")
+ # Otherwise, we use the deployment name and suffix it using the branch name (e.g: "v2-mst-xxx-branch-name")
+ if [[ ${GIT_COMMIT_REF##*/} =~ ^v[0-9]{1,}- ]]; then # Checking if pattern matches with "vX-" where X is a number
+ BRANCH_ALIAS_DOMAIN=${CUSTOMER_REF_TO_DEPLOY}-${{ env.GITHUB_REF_SLUG }}
+ else
+ BRANCH_ALIAS_DOMAIN=$(cat vercel.$CUSTOMER_REF_TO_DEPLOY.staging.json | jq --raw-output '.name')-${{ env.GITHUB_REF_SLUG }}
+ fi
+ echo "Resolved branch domain alias: " $BRANCH_ALIAS_DOMAIN
+ echo "BRANCH_ALIAS_DOMAIN=$BRANCH_ALIAS_DOMAIN" >> $GITHUB_ENV
+
+ # Create a GitHub deployment (within a GitHub environment), useful to keep a public track of all deployments directly in GitHub
+ - name: Start GitHub deployment
+ uses: bobheadxi/deployments@v0.4.3 # See https://github.com/bobheadxi/deployments
+ id: start-github-deployment
+ with:
+ step: start
+ token: ${{ secrets.GITHUB_TOKEN }}
+ env: ${{ env.CUSTOMER_REF_TO_DEPLOY }}-${{ env.STAGE }} # Uses "$customer-$env" as GitHub environment name, because it's easier this way to see what has changed per customer, per stage
+ no_override: true # Disables auto marking previous environments as "inactive", as they're still active (Vercel deployments don't auto-deactivate) and it would remove the previous deployment links needlessly
+
+ - name: Deploying on Vercel (${{ env.STAGE }})
+ uses: UnlyEd/github-action-deploy-on-vercel@94d41ec1ff9b5b1de5256312e385632b6fcd8fa4 # Pin "v1.2.1" - See https://github.com/UnlyEd/github-action-deploy-on-vercel/commit/94d41ec1ff9b5b1de5256312e385632b6fcd8fa4
+ with:
+ command: "yarn deploy:ci:gha --token ${{ secrets.VERCEL_TOKEN }}"
+ extraAliases: >-
+ ${{ env.BRANCH_ALIAS_DOMAIN }}.vercel.app
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} # Passing github's secret to the worker
+ GIT_COMMIT_REF: ${{ github.ref }} # Passing current branch/tag to the worker
+ GIT_COMMIT_SHA: ${{ github.ref }} # Passing current commit SHA to the worker
+ CUSTOMER_REF: ${{ env.CUSTOMER_REF_TO_DEPLOY }} # Passing current customer to deploy
+
+ # Update the previously created GitHub deployment, and link it to our Vercel deployment
+ - name: Link GitHub deployment to Vercel
+ uses: bobheadxi/deployments@v0.4.3 # See https://github.com/bobheadxi/deployments
+ id: link-github-deployment-to-vercel
+ if: always()
+ with:
+ step: finish
+ token: ${{ secrets.GITHUB_TOKEN }}
+ status: ${{ job.status }}
+ deployment_id: ${{ steps.start-github-deployment.outputs.deployment_id }}
+ env_url: ${{ env.VERCEL_DEPLOYMENT_URL }} # Link the Vercel deployment url to the GitHub environment
+
+ # We need to find the PR id. Will be used later to comment on that PR.
+ - name: Finding Pull Request ID
+ uses: jwalton/gh-find-current-pr@v1 # See https://github.com/jwalton/gh-find-current-pr
+ id: pr_id_finder
+ if: always() # It forces the job to be always executed, even if a previous job fail.
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ # On deployment failure, add a comment to the PR, if there is an open PR for the current branch
+ - name: Comment PR (Deployment failure)
+ uses: peter-evans/create-or-update-comment@v1 # See https://github.com/peter-evans/create-or-update-comment
+ if: steps.pr_id_finder.outputs.number && failure()
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ steps.pr_id_finder.outputs.number }}
+ body: |
+ :x: Deployment **FAILED**
+ Commit ${{ github.sha }} failed to deploy to [${{ env.VERCEL_DEPLOYMENT_URL }}](${{ env.VERCEL_DEPLOYMENT_URL }})
+ [click to see logs](https://github.com/UnlyEd/next-right-now/pull/${{ steps.pr_id_finder.outputs.number }}/checks)
+
+ # On deployment success, add a comment to the PR, if there is an open PR for the current branch
+ - name: Comment PR (Deployment success)
+ uses: peter-evans/create-or-update-comment@v1 # See https://github.com/peter-evans/create-or-update-comment
+ if: steps.pr_id_finder.outputs.number && success()
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ steps.pr_id_finder.outputs.number }}
+ body: |
+ :white_check_mark: Deployment **SUCCESS**
+ Commit ${{ github.sha }} successfully deployed **Next.js app** :rocket: to [${{ env.VERCEL_DEPLOYMENT_URL }}](${{ env.VERCEL_DEPLOYMENT_URL }})
+ Deployment aliases (${{ env.VERCEL_ALIASES_CREATED_COUNT }}): ${{ env.VERCEL_ALIASES_CREATED_URLS_MD }}
+
+ # At the end of the job, store all variables we will need in the following jobs
+ # The variables will be stored in and retrieved from a GitHub Artifact (each variable is stored in a different file)
+ - name: Store variables for next jobs
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ with:
+ variables: |
+ CUSTOMER_REF_TO_DEPLOY=${{ env.CUSTOMER_REF_TO_DEPLOY }}
+ VERCEL_DEPLOYMENT_URL=${{ env.VERCEL_DEPLOYMENT_URL }}
+ VERCEL_DEPLOYMENT_DOMAIN=${{ env.VERCEL_DEPLOYMENT_DOMAIN }}
+ MANUAL_TRIGGER_CUSTOMER=${{ env.MANUAL_TRIGGER_CUSTOMER }}
+ GITHUB_PULL_REQUEST_ID=${{ steps.pr_id_finder.outputs.number }}
+
+ # Waits for the Vercel deployment to reach "READY" state, so that other actions will be applied on a domain that is really online
+ await-for-vercel-deployment:
+ name: Await current deployment to be ready (Ubuntu latest)
+ runs-on: ubuntu-latest
+ needs: start-staging-deployment
+ timeout-minutes: 5 # Limit current job timeout (including action timeout setup down there) https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: |
+ VERCEL_DEPLOYMENT_DOMAIN
+
+ # Wait for deployment to be ready, before running E2E (otherwise Cypress might start testing too early, and gets redirected to Vercel's "Login page", and tests fail)
+ - name: Awaiting Vercel deployment to be ready
+ uses: UnlyEd/github-action-await-vercel@v1.2.14 # See https://github.com/UnlyEd/github-action-await-vercel
+ id: await-vercel
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ with:
+ deployment-url: ${{ env.VERCEL_DEPLOYMENT_DOMAIN }} # Must only contain the domain name (no http prefix, etc.)
+ timeout: 90 # Wait for 90 seconds before failing
+
+ - name: Display deployment status
+ run: "echo The deployment is ${{ env.readyState }}"
+
+ # Send a HTTP call to the webhook url that's provided in the customer configuration file (vercel.*.json)
+ send-webhook-callback-once-deployment-ready:
+ name: Invoke webhook callback url defined by the customer (Ubuntu latest)
+ runs-on: ubuntu-latest
+ needs: await-for-vercel-deployment
+ timeout-minutes: 5 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+ - name: Expose GitHub slug/short variables # See https://github.com/rlespinasse/github-slug-action#exposed-github-environment-variables
+ uses: rlespinasse/github-slug-action@v3.x # See https://github.com/rlespinasse/github-slug-action
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: |
+ MANUAL_TRIGGER_CUSTOMER
+ CUSTOMER_REF_TO_DEPLOY
+
+ - name: Expose git environment variables and call webhook (if provided)
+ # Workflow overview:
+ # - Resolves webhook url from customer config file
+ # - If a webhook url was defined in the customer config file, send an HTTP request, as POST request, with a JSON request body dynamically generated
+ # - Prints the headers of the POST HTTP request (curl)
+ # TODO Convert this into an external bash script if possible, to simplify this step as much as possible
+ run: |
+ VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK=$(cat vercel.$CUSTOMER_REF_TO_DEPLOY.${{ env.STAGE }}.json | jq --raw-output '.build.env.VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK')
+ echo "Vercel deployment webhook url: " $VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK
+
+ # Checking if a webhook url is defined
+ if [ -n "$VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK" ]; then
+ # Run script that populates git-related variables as ENV variables
+ echo "Running script generate-post-data"
+ . ./scripts/generate-post-data.sh
+
+ echo "Print generate_post_data():"
+ echo "$(generate_post_data)"
+
+ echo "Calling webhook at '$VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK'"
+ echo "Sending HTTP request (curl):"
+ curl POST \
+ "$VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK" \
+ -vs \
+ --header "Accept: application/json" \
+ --header "Content-type: application/json" \
+ --data "$(generate_post_data)" \
+ 2>&1 | sed '/^* /d; /bytes data]$/d; s/> //; s/< //'
+
+ # XXX See https://stackoverflow.com/a/54225157/2391795
+ # -vs - add headers (-v) but remove progress bar (-s)
+ # 2>&1 - combine stdout and stderr into single stdout
+ # sed - edit response produced by curl using the commands below
+ # /^* /d - remove lines starting with '* ' (technical info)
+ # /bytes data]$/d - remove lines ending with 'bytes data]' (technical info)
+ # s/> // - remove '> ' prefix
+ # s/< // - remove '< ' prefix
+
+ else
+ echo "No webhook url defined in 'vercel.$CUSTOMER_REF_TO_DEPLOY.${{ env.STAGE }}.json:.build.env.VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK' (found '$VERCEL_DEPLOYMENT_COMPLETED_WEBHOOK')"
+ fi
+ env:
+ GIT_COMMIT_REF: ${{ github.ref }} # Passing current branch/tag to the worker
+ GIT_COMMIT_SHA: ${{ github.sha }} # Passing current commit SHA to the worker
+ # Passing exposed GitHub environment variables - See https://github.com/rlespinasse/github-slug-action#exposed-github-environment-variables
+ DEPLOYMENT_STAGE: ${{ env.STAGE }}
+ GITHUB_REF_SLUG: ${{ env.GITHUB_REF_SLUG }}
+ GITHUB_HEAD_REF_SLUG: ${{ env.GITHUB_HEAD_REF_SLUG }}
+ GITHUB_BASE_REF_SLUG: ${{ env.GITHUB_BASE_REF_SLUG }}
+ GITHUB_EVENT_REF_SLUG: ${{ env.GITHUB_EVENT_REF_SLUG }}
+ GITHUB_REPOSITORY_SLUG: ${{ env.GITHUB_REPOSITORY_SLUG }}
+ GITHUB_REF_SLUG_URL: ${{ env.GITHUB_REF_SLUG_URL }}
+ GITHUB_HEAD_REF_SLUG_URL: ${{ env.GITHUB_HEAD_REF_SLUG_URL }}
+ GITHUB_BASE_REF_SLUG_URL: ${{ env.GITHUB_BASE_REF_SLUG_URL }}
+ GITHUB_EVENT_REF_SLUG_URL: ${{ env.GITHUB_EVENT_REF_SLUG_URL }}
+ GITHUB_REPOSITORY_SLUG_URL: ${{ env.GITHUB_REPOSITORY_SLUG_URL }}
+ GITHUB_SHA_SHORT: ${{ env.GITHUB_SHA_SHORT }}
+
+ # Runs E2E tests against the Vercel deployment
+ run-2e2-tests:
+ name: Run end to end (E2E) tests (Ubuntu latest)
+ runs-on: ubuntu-latest
+ # Docker image with Cypress pre-installed
+ # https://github.com/cypress-io/cypress-docker-images/tree/master/included
+ container: cypress/included:7.4.0
+ needs: await-for-vercel-deployment
+ timeout-minutes: 20 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: |
+ VERCEL_DEPLOYMENT_URL
+ GITHUB_PULL_REQUEST_ID
+
+ # Runs the E2E tests against the new Vercel deployment
+ - name: Run E2E tests (Cypress)
+ uses: cypress-io/github-action@v2 # See https://github.com/cypress-io/github-action
+ with:
+ # XXX We disabled "wait-on" option, because it's useless. Cypress will fail anyway, because it gets redirected to some internal Vercel URL if the domain isn't yet available - See https://github.com/cypress-io/github-action/issues/270
+ # wait-on: '${{ env.VERCEL_DEPLOYMENT_URL }}' # Be sure that the endpoint is ready by pinging it before starting tests, using a default timeout of 60 seconds
+ config-file: 'cypress/config-customer-ci-cd.json' # Use Cypress config file for CI/CD, and override it below
+ config: baseUrl=${{ env.VERCEL_DEPLOYMENT_URL }} # Overriding baseUrl provided by config file to test the new deployment
+ env:
+ # Enables Cypress debugging logs, very useful if Cypress crashes, like out-of-memory issues.
+ # DEBUG: "cypress:*" # Enable all logs. See https://docs.cypress.io/guides/references/troubleshooting.html#Print-DEBUG-logs
+ DEBUG: "cypress:server:util:process_profiler" # Enable logs for "memory and CPU usage". See https://docs.cypress.io/guides/references/troubleshooting.html#Log-memory-and-CPU-usage
+
+ # On E2E failure, upload screenshots
+ - name: Upload screenshots artifacts (E2E failure)
+ uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
+ if: failure()
+ with:
+ name: screenshots
+ path: cypress/screenshots/
+
+ # On E2E failure, upload videos
+ - name: Upload videos artifacts (E2E failure)
+ uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
+ if: failure()
+ with:
+ name: videos
+ path: cypress/videos/
+
+ # On E2E failure, add a comment to the PR with additional information, if there is an open PR for the current branch
+ - name: Comment PR (E2E failure)
+ uses: peter-evans/create-or-update-comment@v1 # See https://github.com/peter-evans/create-or-update-comment
+ if: env.GITHUB_PULL_REQUEST_ID && failure()
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ env.GITHUB_PULL_REQUEST_ID }}
+ body: |
+ :x: E2E tests **FAILED** for commit ${{ github.sha }} previously deployed at [${{ env.VERCEL_DEPLOYMENT_URL }}](${{ env.VERCEL_DEPLOYMENT_URL }})
+ Download artifacts (screenshots + videos) from [`checks`](https://github.com/UnlyEd/next-right-now/pull/${{ env.GITHUB_PULL_REQUEST_ID }}/checks) section
+
+ # On E2E success, add a comment to the PR, if there is an open PR for the current branch
+ - name: Comment PR (E2E success)
+ uses: peter-evans/create-or-update-comment@v1 # See https://github.com/peter-evans/create-or-update-comment
+ if: env.GITHUB_PULL_REQUEST_ID && success()
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ env.GITHUB_PULL_REQUEST_ID }}
+ body: |
+ :white_check_mark: E2E tests **SUCCESS** for commit ${{ github.sha }} previously deployed at [${{ env.VERCEL_DEPLOYMENT_URL }}](${{ env.VERCEL_DEPLOYMENT_URL }})
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Runs LightHouse reports in parallel of E2E tests
+ run-lighthouse-tests:
+ name: Run LightHouse checks (Ubuntu latest)
+ runs-on: ubuntu-latest
+ needs: await-for-vercel-deployment
+ timeout-minutes: 5 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # To store reports and then upload them, we must create the folder beforehand
+ - name: Create temporary folder for artifacts storage
+ run: mkdir /tmp/lighthouse-artifacts
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: VERCEL_DEPLOYMENT_URL
+
+ # Runs LightHouse for a given url and create HTML reports in the specified directory (outputDirectory)
+ # Action documentation: https://github.com/marketplace/actions/lighthouse-check#usage-standard-example
+ - name: Run Lighthouse
+ uses: foo-software/lighthouse-check-action@v2.0.5
+ id: lighthouseCheck
+ with: # See https://github.com/marketplace/actions/lighthouse-check#inputs for all options
+ accessToken: ${{ secrets.GITHUB_TOKEN }} # Providing a token to comment the PR if you are using a "pull_request" as trigger, or using "commentUrl" option.
+ commentUrl: https://api.github.com/repos/UnlyEd/next-right-now/commits/${{ github.sha }}/comments
+ prCommentEnabled: true # Whether to comment on the PR (default: true). Disabled because we use our own "Comment PR (LightHouse report)" action
+ prCommentSaveOld: true # If true, then add a new comment for each commit. Otherwise, update the latest comment (default: false).
+ outputDirectory: /tmp/lighthouse-artifacts # Used to upload artifacts.
+ emulatedFormFactor: all # Run LightHouse against "mobile", "desktop", or "all" devices
+ urls: ${{ env.VERCEL_DEPLOYMENT_URL }}, ${{ env.VERCEL_DEPLOYMENT_URL }}/en, ${{ env.VERCEL_DEPLOYMENT_URL }}/fr
+ locale: en
+
+ # Upload HTML report created by lighthouse as an artifact.
+ # XXX Disable this if you don't use them, as they are a bit heavy (~3MB) and might cost you money, if you're using a private repository
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v1
+ with:
+ name: Lighthouse reports
+ path: /tmp/lighthouse-artifacts
+
+ # Using a pre-build action to make the action fail if your score is too low. It can be really interesting to track a low score on a commit
+ # You can remove this action IF you don't want lighthouse to be a blocking point in your CI
+ # Official documentation: https://github.com/foo-software/lighthouse-check-status-action
+ - name: Handle Lighthouse Check results
+ uses: foo-software/lighthouse-check-status-action@v1.0.1 # See https://github.com/foo-software/lighthouse-check-action
+ with:
+ lighthouseCheckResults: ${{ steps.lighthouseCheck.outputs.lighthouseCheckResults }}
+ minAccessibilityScore: "50"
+ minBestPracticesScore: "50"
+ minPerformanceScore: "30"
+ minProgressiveWebAppScore: "50"
+ minSeoScore: "50"
diff --git a/.github/workflows/deploy-vercel-storybook.yml b/.github/workflows/deploy-vercel-storybook.yml
new file mode 100644
index 000000000..7852cf7b6
--- /dev/null
+++ b/.github/workflows/deploy-vercel-storybook.yml
@@ -0,0 +1,259 @@
+# Summary:
+# Builds a static version of the Storybook website and triggers a new deployment on Vercel's platform, when anything is pushed in any branch.
+#
+# LEARN MORE AT https://unlyed.github.io/next-right-now/guides/ci-cd/
+#
+# Dependencies overview:
+# - See https://github.com/actions/setup-node https://github.com/actions/setup-node/tree/v2
+# - See https://github.com/actions/checkout https://github.com/actions/checkout/tree/v1
+# - See https://github.com/actions/upload-artifact https://github.com/actions/upload-artifact/tree/v1
+# - See https://github.com/rlespinasse/github-slug-action https://github.com/rlespinasse/github-slug-action/tree/3.x
+# - See https://github.com/jwalton/gh-find-current-pr https://github.com/jwalton/gh-find-current-pr/tree/v1
+# - See https://github.com/peter-evans/create-or-update-comment https://github.com/peter-evans/create-or-update-comment/tree/v1
+# - See https://github.com/UnlyEd/github-action-await-vercel https://github.com/UnlyEd/github-action-await-vercel/tree/v1.1.1
+# - See https://github.com/UnlyEd/github-action-store-variable https://github.com/UnlyEd/github-action-store-variable/tree/v2.1.1
+# - See https://github.com/cypress-io/github-action https://github.com/cypress-io/github-action/tree/v2
+
+name: Deploy Storybook static site to Vercel
+
+on:
+ # There are several ways to trigger Github actions - See https://help.github.com/en/actions/reference/events-that-trigger-workflows#example-using-a-single-event for a comprehensive list:
+ # - "push": Triggers each time a commit is pushed
+ # - "pull_request": Triggers each time a commit is pushed within a pull request, it makes it much easier to write comments within the PR, but it suffers some strong limitations:
+ # - There is no way to trigger when a PR is merged into another - See https://github.community/t/pull-request-action-does-not-run-on-merge/16092?u=vadorequest
+ # - It won't trigger when the PR is conflicting with its base branch - See https://github.community/t/run-actions-on-pull-requests-with-merge-conflicts/17104/2?u=vadorequest
+ push: # Triggers on each pushed commit
+ branches:
+ - '*'
+
+ # Runs the deployment workflow only when there are changes made to specific files, in any of the below paths
+ # Optimizes our CI/CD by not deploying the Storybook static site needlessly (faster deploy, lower cost)
+ # XXX Filter pattern cheat sheet https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
+ paths:
+ - '.github/workflows/deploy-vercel-storybook.yml'
+ - '.storybook/**'
+ - 'cypress/**'
+ - '!cypress/_examples/**'
+ - '!cypress/fixture/**'
+ - '!cypress/integration/**'
+ - '!cypress/config-*.json' # Exclude all config files
+ - 'cypress/config-storybook.json' # Force include storybook config file
+ - 'src/stories/**'
+ - 'src/common/components/**'
+ - 'src/**/components/**' # Any component in any "components" folder under "src". (e.g: "src/auth/components/comp1", "src/modules/core/auth/components/comp1", etc.)
+ - '!src/app/components/*Bootstrap*' # Ignore bootstrap components, as they aren't used by Storybook
+ - '*.js*' # Includes all .js/.json at the root level
+ - '*.ts' # Includes all .ts at the root level
+ - '.*ignore' # Includes .gitignore and .vercelignore
+ - 'yarn.lock'
+ - '!**/*.md' # Exclude all markdown files
+ - 'stories/**/*.md' # Force include all markdown files within stories (because they're used for storybook)
+
+env:
+ STAGE: staging
+
+jobs:
+ # Configures the deployment environment, install dependencies (like node, npm, etc.) that are requirements for the upcoming jobs
+ # Ex: Necessary to run `yarn deploy`
+ setup-environment:
+ name: Setup deployment environment (Ubuntu latest - Node 14.x)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Installing node.js
+ uses: actions/setup-node@v2 # Used to install node environment - https://github.com/actions/setup-node
+ with:
+ node-version: '14.x' # Use the same node.js version as the one Vercel's uses (currently node14.x)
+
+ # Starts a Vercel deployment, using the storybook configuration file
+ # N.B: It's Vercel that will perform the actual deployment
+ start-deployment:
+ name: Starts Vercel deployment (Ubuntu latest)
+ runs-on: ubuntu-latest
+ timeout-minutes: 40 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ needs: setup-environment
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ - name: Expose GitHub slug/short variables # See https://github.com/rlespinasse/github-slug-action#exposed-github-environment-variables
+ uses: rlespinasse/github-slug-action@v3.x # See https://github.com/rlespinasse/github-slug-action
+
+ # Create a GitHub deployment (within a GitHub environment), useful to keep a public track of all deployments directly in GitHub
+ - name: Start GitHub deployment
+ uses: bobheadxi/deployments@v0.4.3 # See https://github.com/bobheadxi/deployments
+ id: start-github-deployment
+ with:
+ step: start
+ token: ${{ secrets.GITHUB_TOKEN }}
+ env: storybook # Uses "storybook" as GitHub environment name, because we don't need to manage multiple environments for storybook
+ no_override: true # Disables auto marking previous environments as "inactive", as they're still active (Vercel deployments don't auto-deactivate) and it would remove the previous deployment links needlessly
+
+ - name: Install storybook dependencies
+ run: yarn install
+
+ - name: Deploying on Vercel
+ uses: UnlyEd/github-action-deploy-on-vercel@94d41ec1ff9b5b1de5256312e385632b6fcd8fa4 # Pin "v1.2.1" - See https://github.com/UnlyEd/github-action-deploy-on-vercel/commit/94d41ec1ff9b5b1de5256312e385632b6fcd8fa4
+ with:
+ command: "yarn deploy:sb:gha --token ${{ secrets.VERCEL_TOKEN }}"
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} # Passing github's secret to the worker
+ # Passing exposed GitHub environment variables - See https://github.com/rlespinasse/github-slug-action#exposed-github-environment-variables
+ GITHUB_REF_SLUG: ${{ env.GITHUB_REF_SLUG }}
+
+ # Update the previously created GitHub deployment, and link it to our Vercel deployment
+ - name: Link GitHub deployment to Vercel
+ uses: bobheadxi/deployments@v0.4.3 # See https://github.com/bobheadxi/deployments
+ id: link-github-deployment-to-vercel
+ if: always()
+ with:
+ step: finish
+ token: ${{ secrets.GITHUB_TOKEN }}
+ status: ${{ job.status }}
+ deployment_id: ${{ steps.start-github-deployment.outputs.deployment_id }}
+ env_url: ${{ env.VERCEL_DEPLOYMENT_URL }} # Link the Vercel deployment url to the GitHub environment
+
+ # We need to find the PR id. Will be used later to comment on that PR.
+ - name: Finding Pull Request ID
+ uses: jwalton/gh-find-current-pr@v1 # See https://github.com/jwalton/gh-find-current-pr
+ id: pr_id_finder
+ if: always() # It forces the job to be always executed, even if a previous job fail.
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
+ # On deployment failure, add a comment to the PR, if there is an open PR for the current branch
+ - name: Comment PR (Deployment failure)
+ uses: peter-evans/create-or-update-comment@v1 # See https://github.com/peter-evans/create-or-update-comment
+ if: steps.pr_id_finder.outputs.number && failure()
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ steps.pr_id_finder.outputs.number }}
+ body: |
+ :x: Deployment **FAILED**
+ Commit ${{ github.sha }} failed to deploy **Storybook static site** to [${{ env.VERCEL_DEPLOYMENT_URL }}](${{ env.VERCEL_DEPLOYMENT_URL }})
+ [click to see logs](https://github.com/UnlyEd/next-right-now/pull/${{ steps.pr_id_finder.outputs.number }}/checks)
+
+ # On deployment success, add a comment to the PR, if there is an open PR for the current branch
+ - name: Comment PR (Deployment success)
+ uses: peter-evans/create-or-update-comment@v1 # See https://github.com/peter-evans/create-or-update-comment
+ if: steps.pr_id_finder.outputs.number && success()
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ steps.pr_id_finder.outputs.number }}
+ body: |
+ :white_check_mark: Deployment **SUCCESS**
+ Commit ${{ github.sha }} successfully deployed **Storybook static site** :open_book: to [${{ env.VERCEL_DEPLOYMENT_URL }}](${{ env.VERCEL_DEPLOYMENT_URL }})
+ Deployment aliases (${{ env.VERCEL_ALIASES_CREATED_COUNT }}): ${{ env.VERCEL_ALIASES_CREATED_URLS_MD }}
+
+ # At the end of the job, store all variables we will need in the following jobs
+ # The variables will be stored in and retrieved from a GitHub Artifact (each variable is stored in a different file)
+ - name: Store variables for next jobs
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ with:
+ variables: |
+ VERCEL_DEPLOYMENT_URL=${{ env.VERCEL_DEPLOYMENT_URL }}
+ VERCEL_DEPLOYMENT_DOMAIN=${{ env.VERCEL_DEPLOYMENT_DOMAIN }}
+ GITHUB_PULL_REQUEST_ID=${{ steps.pr_id_finder.outputs.number }}
+
+ # Waits for the Vercel deployment to reach "READY" state, so that other actions will be applied on a domain that is really online
+ await-for-vercel-deployment:
+ name: Await current deployment to be ready (Ubuntu latest)
+ timeout-minutes: 5 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ runs-on: ubuntu-latest
+ needs: start-deployment
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: |
+ VERCEL_DEPLOYMENT_DOMAIN
+
+ # Wait for deployment to be ready, before running E2E (otherwise Cypress might start testing too early, and gets redirected to Vercel's "Login page", and tests fail)
+ - name: Awaiting Vercel deployment to be ready
+ uses: UnlyEd/github-action-await-vercel@v1.1.1 # See https://github.com/UnlyEd/github-action-await-vercel
+ id: await-vercel
+ env:
+ VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+ with:
+ deployment-url: ${{ env.VERCEL_DEPLOYMENT_DOMAIN }} # Must only contain the domain name (no http prefix, etc.)
+ timeout: 90 # Wait for 90 seconds before failing
+
+ - name: Display deployment status
+ run: "echo The deployment is ${{ fromJson(steps.await-vercel.outputs.deploymentDetails).readyState }}"
+
+ # Runs E2E tests against the Vercel deployment
+ run-2e2-tests:
+ name: Run end to end (E2E) tests (Ubuntu latest)
+ timeout-minutes: 20 # Limit current job timeout https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idtimeout-minutes
+ runs-on: ubuntu-latest
+ # Docker image with Cypress pre-installed
+ # https://github.com/cypress-io/cypress-docker-images/tree/master/included
+ container: cypress/included:7.4.0
+ needs: await-for-vercel-deployment
+ steps:
+ - uses: actions/checkout@v1 # Get last commit pushed - See https://github.com/actions/checkout
+
+ # Restore variables stored by previous jobs
+ - name: Restore variables
+ uses: UnlyEd/github-action-store-variable@v2.1.1 # See https://github.com/UnlyEd/github-action-store-variable
+ id: restore-variable
+ with:
+ failIfNotFound: true
+ variables: |
+ VERCEL_DEPLOYMENT_URL
+ GITHUB_PULL_REQUEST_ID
+
+ # Runs the E2E tests against the new Vercel deployment
+ - name: Run E2E tests (Cypress)
+ uses: cypress-io/github-action@v2 # See https://github.com/cypress-io/github-action
+ with:
+ # XXX We disabled "wait-on" option, because it's useless. Cypress will fail anyway, because it gets redirected to some internal Vercel URL if the domain isn't yet available - See https://github.com/cypress-io/github-action/issues/270
+ # wait-on: '${{ env.VERCEL_DEPLOYMENT_URL }}' # Be sure that the endpoint is ready by pinging it before starting tests, using a default timeout of 60 seconds
+ config-file: 'cypress/config-storybook.json' # Use Cypress config file for Storybook, and override it below
+ config: baseUrl=${{ env.VERCEL_DEPLOYMENT_URL }} # Overriding baseUrl provided by config file to test the new deployment
+ env:
+ # Enables Cypress debugging logs, very useful if Cypress crashes, like out-of-memory issues.
+ # DEBUG: "cypress:*" # Enable all logs. See https://docs.cypress.io/guides/references/troubleshooting.html#Print-DEBUG-logs
+ DEBUG: "cypress:server:util:process_profiler" # Enable logs for "memory and CPU usage". See https://docs.cypress.io/guides/references/troubleshooting.html#Log-memory-and-CPU-usage
+
+ # On E2E failure, upload screenshots
+ - name: Upload screenshots artifacts (E2E failure)
+ uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
+ if: failure()
+ with:
+ name: screenshots
+ path: cypress/screenshots/
+
+ # On E2E failure, upload videos
+ - name: Upload videos artifacts (E2E failure)
+ uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
+ if: failure()
+ with:
+ name: videos
+ path: cypress/videos/
+
+ # On E2E failure, add a comment to the PR with additional information, if there is an open PR for the current branch
+ - name: Comment PR (E2E failure)
+ uses: peter-evans/create-or-update-comment@v1 # See https://github.com/peter-evans/create-or-update-comment
+ if: env.GITHUB_PULL_REQUEST_ID && failure()
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ env.GITHUB_PULL_REQUEST_ID }}
+ body: |
+ :x: E2E tests **FAILED** for commit ${{ github.sha }} previously deployed **Storybook static site** at [${{ env.VERCEL_DEPLOYMENT_URL }}](${{ env.VERCEL_DEPLOYMENT_URL }})
+ Download artifacts (screenshots + videos) from [`checks`](https://github.com/UnlyEd/next-right-now/pull/${{ env.GITHUB_PULL_REQUEST_ID }}/checks) section
+
+ # On E2E success, add a comment to the PR, if there is an open PR for the current branch
+ - name: Comment PR (E2E success)
+ uses: peter-evans/create-or-update-comment@v1 # See https://github.com/peter-evans/create-or-update-comment
+ if: env.GITHUB_PULL_REQUEST_ID && success()
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ issue-number: ${{ env.GITHUB_PULL_REQUEST_ID }}
+ body: |
+ :white_check_mark: E2E tests **SUCCESS** for commit ${{ github.sha }} previously deployed **Storybook static site** at [${{ env.VERCEL_DEPLOYMENT_URL }}](${{ env.VERCEL_DEPLOYMENT_URL }})
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/deploy-zeit-production.yml b/.github/workflows/deploy-zeit-production.yml
deleted file mode 100644
index 8c8394eb8..000000000
--- a/.github/workflows/deploy-zeit-production.yml
+++ /dev/null
@@ -1,81 +0,0 @@
-# Summary:
-# Creates a new deployment on Zeit's platform, when anything is pushed in any branch (except for the "master" branch).
-# Read ./README.md for extensive documentation
-
-name: Deploy to Zeit (production)
-
-on:
- push:
- branches:
- - 'master'
-
-jobs:
- # Configures the deployment environment, install dependencies (like node, npm, etc.) that are requirements for the upcoming jobs
- # Ex: Necessary to run `yarn deploy`
- setup-environment:
- name: Setup deployment environment (Ubuntu 18.04 - Node 10.x)
- runs-on: ubuntu-18.04
- steps:
- - name: Installing node.js
- uses: actions/setup-node@v1 # Used to install node environment - XXX https://github.com/actions/setup-node
- with:
- node-version: '10.x' # Use the same node.js version as the one Zeit's uses (currently node10.x)
-
- # Starts a Zeit deployment, using the production configuration file of the default institution
- # The default institution is the one defined in the `now.json` file (which is a symlink to the actual file)
- # N.B: It's Zeit that will perform the actual deployment
- start-production-deployment:
- name: Starts Zeit deployment (production) (Ubuntu 18.04)
- runs-on: ubuntu-18.04
- needs: setup-environment
- steps:
- - uses: actions/checkout@v1 # Get last commit pushed - XXX See https://github.com/actions/checkout
- - name: Deploying on Zeit
- run: yarn deploy:$(cat now.json | jq -r '.build.env.CUSTOMER_REF'):production --token $ZEIT_TOKEN
- env:
- ZEIT_TOKEN: ${{ secrets.ZEIT_TOKEN }} # Passing github's secret to the worker
-
- # Runs E2E tests against the Zeit deployment
- run-2e2-tests:
- name: Run end to end (E2E) tests (Ubuntu 18.04)
- runs-on: ubuntu-18.04
- # Docker image with Cypress pre-installed
- # https://github.com/cypress-io/cypress-docker-images/tree/master/included
- container: cypress/included:3.8.3
- needs: start-production-deployment
- steps:
- - uses: actions/checkout@v1 # Get last commit pushed - XXX See https://github.com/actions/checkout
- - name: Resolving deployment url from Zeit
- # The following workflow is:
- # - getting all deployments data (by using the scope in `now.json`)
- # - then we get the last url (in Node.js it corresponds as `response.deployments[0].url`
- # - and then we remove the `"` character to pre-format url
- # We need to set env the url for next step, formatted as `https://${url provided by API}`
- run: |
- apt update -y >/dev/null && apt install -y jq >/dev/null
- ZEIT_DEPLOYMENT=`curl -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${{ secrets.ZEIT_TOKEN }}' https://api.zeit.co/v5/now/deployments?teamId=$(cat now.json | jq -r '.scope') | jq '.deployments [0].url' | tr -d \"`
- echo "::set-env name=ZEIT_DEPLOYMENT_URL::https://$ZEIT_DEPLOYMENT"
-
- # Run the E2E tests against the new Zeit deployment
- - name: Run E2E tests (Cypress)
- uses: cypress-io/github-action@v1 # XXX See https://github.com/cypress-io/github-action
- with:
- wait-on: ${{ env.ZEIT_DEPLOYMENT_URL }} # Be sure that the endpoint is ready by pinging it before starting tests, it has a timeout of 60seconds
- config-file: cypress/config-customer1.json # The config file itself doesn't matter because we will override most settings anyway. We just need `projectId` to run the tests.
- config: baseUrl=${{ env.ZEIT_DEPLOYMENT_URL }} # Overriding baseUrl provided by config file to test the new deployment
-
- # On E2E failure, upload screenshots
- - name: Uplad screenshots artifacts (E2E failure)
- uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
- if: failure()
- with:
- name: screenshots
- path: cypress/screenshots/
-
- # On E2E failure, upload videos
- - name: Uplad videos artifacts (E2E failure)
- uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
- if: failure()
- with:
- name: videos
- path: cypress/videos/
diff --git a/.github/workflows/deploy-zeit-staging.yml b/.github/workflows/deploy-zeit-staging.yml
deleted file mode 100644
index 2cecbe90b..000000000
--- a/.github/workflows/deploy-zeit-staging.yml
+++ /dev/null
@@ -1,144 +0,0 @@
-# Summary:
-# Creates a new deployment on Zeit's platform, when anything is pushed in any branch (except for the "master" branch).
-# Read ./README.md for extensive documentation
-
-name: Deploy to Zeit (staging)
-
-on:
- push:
- branches-ignore:
- - 'master'
-
-jobs:
- # Configures the deployment environment, install dependencies (like node, npm, etc.) that are requirements for the upcoming jobs
- # Ex: Necessary to run `yarn deploy`
- setup-environment:
- name: Setup deployment environment (Ubuntu 18.04 - Node 10.x)
- runs-on: ubuntu-18.04
- steps:
- - name: Installing node.js
- uses: actions/setup-node@v1 # Used to install node environment - XXX https://github.com/actions/setup-node
- with:
- node-version: '10.x' # Use the same node.js version as the one Zeit's uses (currently node10.x)
-
- # Starts a Zeit deployment, using the staging configuration file of the default institution
- # The default institution is the one defined in the `now.json` file (which is a symlink to the actual file)
- # N.B: It's Zeit that will perform the actual deployment
- start-staging-deployment:
- name: Starts Zeit deployment (staging) (Ubuntu 18.04)
- runs-on: ubuntu-18.04
- needs: setup-environment
- steps:
- - uses: actions/checkout@v1 # Get last commit pushed - XXX See https://github.com/actions/checkout
- - name: Deploying on Zeit
- # Workflow:
- # - Get stdout from deploy command (stderr shows build steps and stdout shows final url, what we are looking for)
- # - Set deployment url to show on PR message
- # - Create alias and link it
- run: |
- ZEIT_DEPLOYMENT_OUTPUT=`yarn deploy:$(cat now.json | jq -r '.build.env.CUSTOMER_REF') --token $ZEIT_TOKEN`
-
- ZEIT_DEPLOYMENT_URL=`echo $ZEIT_DEPLOYMENT_OUTPUT | egrep -o 'https?://[^ ]+.now.sh'`
- echo "::set-env name=ZEIT_DEPLOYMENT_URL::$ZEIT_DEPLOYMENT_URL"
-
- if [[ ${CURRENT_BRANCH##*/} =~ ^v[0-9]{1,}- ]]; then # Checking if pattern matches with "vX-" where X is a number
- ZEIT_DEPLOYMENT_ALIAS=${CURRENT_BRANCH##*/}
- else
- ZEIT_DEPLOYMENT_ALIAS=$(cat now.json | jq -r '.name')-${CURRENT_BRANCH##*/}
- fi
-
- # Zeit alias only allows 53 characters in the domain name, so we only keep the first 46 characters (because Zeit needs 7 chars for ".now.sh" at the end of the domain name)
- # Also, in order to remove forbidden characters, we create a sequence from ascii numbers using ranges of numbers (we forbid characters by using `seq X Y` and we add others by using ';').
- # All special characters are converted to `-` (using `tr $0 $1` where $0 is replaced by $1), and only 0-9 and a-Z chars are kept intact.
- # We then use `awk` to convert "ascii numbers" back into actual characters.
- # You can find the numbers equivalence by tapping `man ascii` and look at the "decimal" set.
- ZEIT_DEPLOYMENT_ALIAS=$(echo $ZEIT_DEPLOYMENT_ALIAS | head -c 46 | tr "`(seq 0 47 ; seq 58 64 ; seq 91 96 && seq 123 127) | awk '{printf("%c",$1)}'`" -).now.sh
- echo "::set-env name=ZEIT_DEPLOYMENT_ALIAS::https://$ZEIT_DEPLOYMENT_ALIAS"
-
- npx now alias $ZEIT_DEPLOYMENT_URL https://$ZEIT_DEPLOYMENT_ALIAS --token $ZEIT_TOKEN
- env:
- ZEIT_TOKEN: ${{ secrets.ZEIT_TOKEN }} # Passing github's secret to the worker
- CURRENT_BRANCH: ${{ github.ref }} # Passing current branch to worker
-
- # On deployment failure, add a comment to the PR
- - name: Comment PR (Deployment failure)
- uses: unsplash/comment-on-pr@master
- if: failure()
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_CI_PR_COMMENT }}
- with:
- msg: "[GitHub Actions]\nDeployment FAILED\n\t Commit ${{ github.sha }} failed to deploy to ${{ env.ZEIT_DEPLOYMENT_URL }} (click to see logs)"
-
- # On deployment success, add a comment to the PR
- - name: Comment PR (Deployment success)
- uses: unsplash/comment-on-pr@master
- if: success()
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_CI_PR_COMMENT }}
- with:
- msg: "[GitHub Actions]\nDeployment SUCCESS\n\t Commit ${{ github.sha }} successfully deployed to ${{ env.ZEIT_DEPLOYMENT_URL }}\n\tDeployment aliased as ${{ env.ZEIT_DEPLOYMENT_ALIAS }}"
-
- # Runs E2E tests against the Zeit deployment
- run-2e2-tests:
- name: Run end to end (E2E) tests (Ubuntu 18.04)
- runs-on: ubuntu-18.04
- # Docker image with Cypress pre-installed
- # https://github.com/cypress-io/cypress-docker-images/tree/master/included
- container: cypress/included:3.8.3
- needs: start-staging-deployment
- steps:
- - uses: actions/checkout@v1 # Get last commit pushed - XXX See https://github.com/actions/checkout
- - name: Resolving deployment url from Zeit
- # The following workflow is:
- # - getting all deployments data (by using the scope in `now.json`)
- # - then we get the last url (in Node.js it corresponds as `response.deployments[0].url`
- # - and then we remove the `"` character to pre-format url
- # We need to set env the url for next step, formatted as `https://${url provided by API}`
- run: |
- apt update -y >/dev/null && apt install -y jq >/dev/null
- ZEIT_DEPLOYMENT=`curl -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'Authorization: Bearer ${{ secrets.ZEIT_TOKEN }}' https://api.zeit.co/v5/now/deployments?teamId=$(cat now.json | jq -r '.scope') | jq '.deployments [0].url' | tr -d \"`
- echo "::set-env name=ZEIT_DEPLOYMENT_URL::https://$ZEIT_DEPLOYMENT"
- env:
- ZEIT_TOKEN: ${{ secrets.ZEIT_TOKEN }} # Passing github's secret to the worker
-
- # Run the E2E tests against the new Zeit deployment
- - name: Run E2E tests (Cypress)
- uses: cypress-io/github-action@v1 # XXX See https://github.com/cypress-io/github-action
- with:
- wait-on: ${{ env.ZEIT_DEPLOYMENT_URL }} # Be sure that the endpoint is ready by pinging it before starting tests, it has a timeout of 60seconds
- config-file: cypress/config-customer1.json # The config file itself doesn't matter because we will override most settings anyway. We just need `projectId` to run the tests.
- config: baseUrl=${{ env.ZEIT_DEPLOYMENT_URL }} # Overriding baseUrl provided by config file to test the new deployment
-
- # On E2E failure, upload screenshots
- - name: Uplad screenshots artifacts (E2E failure)
- uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
- if: failure()
- with:
- name: screenshots
- path: cypress/screenshots/
-
- # On E2E failure, upload videos
- - name: Uplad videos artifacts (E2E failure)
- uses: actions/upload-artifact@v1 # On failure we upload artifacts, https://help.github.com/en/actions/automating-your-workflow-with-github-actions/persisting-workflow-data-using-artifacts
- if: failure()
- with:
- name: videos
- path: cypress/videos/
-
- # On E2E failure, add a comment to the PR with additional information
- - name: Comment PR (E2E failure)
- uses: unsplash/comment-on-pr@master
- if: failure()
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_CI_PR_COMMENT }}
- with:
- msg: "[GitHub Actions]\nE2E tests FAILED\n Download artifacts (screenshots + videos) from `checks` section at the top"
-
- # On E2E success, add a comment to the PR
- - name: Comment PR (E2E success)
- uses: unsplash/comment-on-pr@master
- if: success()
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_CI_PR_COMMENT }}
- with:
- msg: "[GitHub Actions]\nE2E tests SUCCESS"
diff --git a/.github/workflows/update-codeclimate-coverage.yml b/.github/workflows/update-codeclimate-coverage.yml
new file mode 100644
index 000000000..261fed320
--- /dev/null
+++ b/.github/workflows/update-codeclimate-coverage.yml
@@ -0,0 +1,40 @@
+# Summary:
+# Runs unit and coverage tests, then uploads the coverage results to the Code Climate dashboard.
+#
+# LEARN MORE AT https://unlyed.github.io/next-right-now/guides/ci-cd/
+#
+# Dependencies overview:
+# - See https://github.com/actions/setup-node https://github.com/actions/setup-node/tree/v2
+# - https://github.com/paambaati/codeclimate-action https://github.com/paambaati/codeclimate-action/tree/v2.6.0
+
+name: Update Code Climate test coverage
+
+on:
+ push:
+ branches:
+ - 'v2-mst-aptd-at-lcz-sty' # Change this branch name by your CodeClimate "main" branch
+
+jobs:
+ # Configures the deployment environment, install dependencies (like node, npm, etc.) that are requirements for the upcoming jobs
+ # Ex: Necessary to run `yarn test:coverage`
+ setup-environment:
+ name: Setup deployment environment (Ubuntu latest - Node 14.x)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Installing node.js
+ uses: actions/setup-node@v2 # Used to install node environment - XXX https://github.com/actions/setup-node
+ with:
+ node-version: '14.x' # Use the same node.js version as the one Vercel's uses (currently node14.x)
+
+ run-tests-coverage:
+ name: Run tests coverage and send report to Code Climate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v1
+ - name: Installing dependencies
+ run: yarn install
+ - uses: paambaati/codeclimate-action@v2.6.0
+ env:
+ CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} # XXX Define this secret in "Github repo > Settings > Secrets", you can get it from Code Climate in "Repo settings > Test coverage".
+ with:
+ coverageCommand: yarn test:coverage:group:no-integration
diff --git a/.gitignore b/.gitignore
index 95f6f4fbe..509e31c1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,6 +69,7 @@ fabric.properties
*.iml
modules.xml
.idea/misc.xml
+.idea/codeStyles
*.ipr
vcs.xml
@@ -118,9 +119,10 @@ coverage/
/.next/
/out/
-# misc
-.env*
-!.env.build.example
+# Next.js environment variables - See https://nextjs.org/docs/basic-features/environment-variables#exposing-environment-variables
+.env.build
+.env*.local
+!.env*.example
# debug
npm-debug.log*
@@ -135,10 +137,20 @@ cypress/videos
src/svg/*.js
src/svg/*.tsx
-# Zeit
+# Vercel
.now
+.vercel
# Jekyll - Github Pages
_site
.sass-cache
.jekyll-metadata
+
+# Tmp files (cache, etc.)
+*.cache
+
+# Storybook
+storybook-static/
+
+# Jest
+.jest-test-results.json
diff --git a/.graphqlconfig b/.graphqlconfig
index ae939895e..32115e290 100644
--- a/.graphqlconfig
+++ b/.graphqlconfig
@@ -3,7 +3,7 @@
"extensions": {
"endpoints": {
"master": {
- "url": "https://api-euwest.graphcms.com/v1/ck73ixhlv09yt01dv2ga1bkbp/master",
+ "url": "https://api-eu-central-1.graphcms.com/v2/ck73ixhlv09yt01dv2ga1bkbp/master",
"introspect": true,
"headers": {
"user-agent": "JS GraphQL",
diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml
new file mode 100644
index 000000000..d23208fbb
--- /dev/null
+++ b/.idea/jsLibraryMappings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.nowignore b/.nowignore
deleted file mode 100644
index 4cbc376fd..000000000
--- a/.nowignore
+++ /dev/null
@@ -1,14 +0,0 @@
-# Optimize speed/size of deployments by not uploading those files
-.idea/
-node_modules/
-cypress/
-coverage/
-.codeclimate.yml
-.editorconfig
-.env.build.example
-.graphqlconfig
-buildspec.yml
-README.md
-schema.graphql
-yarn.lock
-yarn-error.log
diff --git a/.nvmrc b/.nvmrc
index 59db31cba..158c00641 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v12.16.1
+v14.16.0
diff --git a/.storybook/.gitignore b/.storybook/.gitignore
new file mode 100644
index 000000000..f3e842096
--- /dev/null
+++ b/.storybook/.gitignore
@@ -0,0 +1 @@
+*.cache*
diff --git a/.storybook/babel.config.js b/.storybook/babel.config.js
new file mode 100644
index 000000000..ff1dbcd83
--- /dev/null
+++ b/.storybook/babel.config.js
@@ -0,0 +1,13 @@
+/**
+ * Babel configuration for Storybook
+ *
+ * Doesn't affect Next.js babel configuration, specific file for Storybook only.
+ * Need to apply Emotion babel configuration, otherwise Emotion "css" cannot be used in Storybook.
+ *
+ * XXX We use the "classic" way instead of the "automatic" way for Storybook, that's because MDX isn't compatible with "automatic".
+ *
+ * @see https://emotion.sh/docs/css-prop#babel-preset Configuring Emotion 11
+ */
+module.exports = {
+ "presets": ["@emotion/babel-preset-css-prop"]
+};
diff --git a/.storybook/jsconfig.json b/.storybook/jsconfig.json
new file mode 100644
index 000000000..84c874b98
--- /dev/null
+++ b/.storybook/jsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/app/*": [
+ "../src/app/*"
+ ],
+ "@/common/*": [
+ "../src/common/*"
+ ],
+ "@/components/*": [
+ "../src/common/components/*"
+ ],
+ "@/utils/*": [
+ "../src/common/utils/*"
+ ],
+ "@/layouts/*": [
+ "../src/layouts/*"
+ ],
+ "@/modules/*": [
+ "../src/modules/*"
+ ],
+ "@/pages/*": [
+ "../src/pages/*"
+ ]
+ }
+ }
+}
diff --git a/.storybook/main.js b/.storybook/main.js
new file mode 100644
index 000000000..c6d9efe68
--- /dev/null
+++ b/.storybook/main.js
@@ -0,0 +1,255 @@
+const { promises: fs } = require('fs');
+const path = require('path');
+const fetch = require('node-fetch');
+
+const toPath = (_path) => path.join(process.cwd(), _path);
+
+try {
+ require('../.jest-test-results.json');
+ console.warn(`[Storybook Jest config] Found "/.jest-test-results.json". \nStories will display the latest Jest test results. \nRunning "yarn:test" in parallel of storybook will keep Storybook up-to-date with the latest test result.`);
+} catch (e) {
+ console.warn(`[Storybook Jest config] The test results file couldn't be found in "/.jest-test-results.json". \nStories will not display Jest test results. \nRunning "yarn test:generate-output" prior to running storybook will fix this.`);
+}
+
+/**
+ * Fetches translations from Locize and store them in the filesystem.
+ * They will be loaded in preview.js, which will configure Locize so that components can display their translations.
+ *
+ * @param environment
+ */
+const fetchLocizeTranslation = async (environment) => {
+ const cacheFileName = '.sb-translations.cache.json';
+ const version = environment === 'development' ? 'latest' : 'production';
+ const languages = ['en', 'fr'];
+ const namespaces = ['common'];
+ const allI18nTranslations = {};
+
+ for (let i = 0; i < languages.length; i++) {
+ const lang = languages[i];
+ for (let j = 0; j < namespaces.length; j++) {
+ const namespace = namespaces[j];
+ const locizeAPIEndpoint = `https://api.locize.app/${process.env.NEXT_PUBLIC_LOCIZE_PROJECT_ID}/${version}/${lang}/${namespace}`;
+ console.log('Fetching translations from:', locizeAPIEndpoint);
+ const defaultI18nTranslationsResponse = await fetch(locizeAPIEndpoint);
+ const i18nTranslations = await defaultI18nTranslationsResponse.json();
+
+ allI18nTranslations[lang] = allI18nTranslations[lang] || {};
+ allI18nTranslations[lang][namespace] = i18nTranslations;
+ }
+ }
+
+ // Store translations
+ const translationCacheFile = path.join(__dirname, cacheFileName);
+ console.log('Writing translations cache to:', translationCacheFile);
+
+ await fs.writeFile(translationCacheFile, JSON.stringify(allI18nTranslations, null, 2), 'utf8');
+};
+
+module.exports = {
+ stories: [
+ '../src/**/*.stories.mdx',
+ '../src/**/*.stories.@(js|jsx|ts|tsx)',
+ ],
+ addons: [
+ /**
+ * The Storybook Links addon can be used to create links that navigate between stories in Storybook.
+ *
+ * @see https://www.npmjs.com/package/@storybook/addon-links
+ */
+ '@storybook/addon-links',
+
+ /**
+ * Present including "essential" Storybook addons, such as:
+ *
+ * - Actions - Storybook Addon Actions can be used to display data received by event handlers in Storybook.
+ * It's where the action you do are being logged.
+ *
+ * - Backgrounds - Storybook Addon Backgrounds can be used to change background colors inside the preview in Storybook.
+ *
+ * - Controls - Controls gives you a graphical UI to interact with a component's arguments dynamically, without needing to code.
+ * It creates an addon panel next to your component examples ("stories"), so you can edit them live.
+ *
+ * - Docs - Storybook Docs transforms your Storybook stories into world-class component documentation.
+ * - DocsPage: Out of the box, all your stories get a DocsPage. DocsPage is a zero-config aggregation
+ * of your component stories, text descriptions, docgen comments, props tables, and code examples into clean, readable pages.
+ * - MDX: If you want more control, MDX allows you to write long-form markdown documentation and stories in one file.
+ * You can also use it to write pure documentation pages and embed them inside your Storybook alongside your stories.
+ *
+ * - Viewport - Storybook Viewport Addon allows your stories to be displayed in different sizes and layouts in Storybook.
+ * This helps build responsive components inside of Storybook.
+ *
+ * - Toolbars - The Toolbars addon controls global story rendering options from Storybook's toolbar UI. It's a general purpose addon that can be used to:
+ * - set a theme for your components
+ * - set your components' internationalization (i18n) locale
+ * - configure just about anything in Storybook that makes use of a global variable
+ *
+ * @see https://storybook.js.org/addons/essentials
+ * @see https://github.com/storybookjs/storybook/tree/master/addons/essentials
+ * @see https://github.com/storybookjs/storybook/tree/next/addons/actions
+ * @see https://github.com/storybookjs/storybook/tree/next/addons/backgrounds
+ * @see https://github.com/storybookjs/storybook/tree/next/addons/controls
+ * @see https://github.com/storybookjs/storybook/tree/next/addons/docs
+ * @see https://github.com/storybookjs/storybook/tree/next/addons/viewport
+ * @see https://github.com/storybookjs/storybook/tree/next/addons/toolbars
+ *
+ * You can disable addons you don't want through configuration.
+ * @see https://github.com/storybookjs/storybook/tree/master/addons/essentials#disabling-addons
+ */
+ {
+ name: '@storybook/addon-essentials',
+ options: {
+ actions: true,
+ backgrounds: true,
+ controls: true,
+ docs: true,
+ viewport: true,
+ toolbars: true,
+ },
+ },
+
+ /**
+ * Storybook Addon Knobs has been replaced by Controls and is being deprecated, it will be removed in v7.
+ *
+ * It is listed below for documentation purpose and help you avoid using it thinking it's still legit.
+ *
+ * @see https://github.com/storybookjs/storybook/blob/next/addons/controls/README.md#how-will-this-replace-addon-knobs
+ */
+ // '@storybook/addon-knobs',
+
+ /**
+ * We use Google Analytics for tracking analytics usage.
+ *
+ * It's much easier to setup than Amplitude, because there is an official dedicated plugin for this.
+ * See ".storybook/manager.js" for Google Analytics configuration.
+ *
+ * @see https://github.com/storybookjs/storybook/tree/master/addons/google-analytics
+ */
+ '@storybook/addon-google-analytics',
+
+ /**
+ * Shows stories source in the addon panel. (display the source code of the story in a dedicated panel)
+ *
+ * Adds an "Story" tab.
+ *
+ * XXX Disabled for now, because of https://github.com/storybookjs/storybook/issues/13657 (brings no useful information at the moment)
+ * Better to use the Docs panel and display source code, that's a good workaround for now.
+ *
+ * @see https://github.com/storybookjs/storybook/tree/master/addons/storysource
+ */
+ // '@storybook/addon-storysource',
+
+ /**
+ * This storybook addon can be helpful to make your UI components more accessible.
+ *
+ * Adds an "Accessibility" tab.
+ *
+ * @see https://www.npmjs.com/package/@storybook/addon-a11y
+ */
+ '@storybook/addon-a11y',
+
+ // ------------------- Non official addons below ------------------
+
+ /**
+ * Adds support for CSS Modules.
+ *
+ * Even though Next Right Now doesn't encourage the use of CSS Modules,
+ * we thought it's an interesting feature to support, which is natively supported by Next.js.
+ *
+ * @see https://www.npmjs.com/package/storybook-css-modules-preset How to configure Storybook to support CSS Modules
+ * @see https://nextjs.org/docs/basic-features/built-in-css-support#adding-component-level-css How to use CSS Modules with Next.js
+ */
+ 'storybook-css-modules-preset',
+
+ /**
+ * Brings Jest results in storybook.
+ *
+ * @see https://github.com/storybookjs/storybook/tree/master/addons/jest
+ */
+ '@storybook/addon-jest',
+
+ /**
+ * A storybook addon to help better understand and debug performance for React components.
+ *
+ * Adds a "Performance" tab.
+ *
+ * @see https://github.com/atlassian-labs/storybook-addon-performance
+ */
+ 'storybook-addon-performance/register',
+
+ /**
+ * Offers suggestions on how you can improve the HTML, CSS and UX of your components to be more mobile-friendly.
+ *
+ * Adds a "Mobile" tab.
+ *
+ * @see https://github.com/aholachek/storybook-mobile
+ */
+ 'storybook-mobile',
+
+ /**
+ * A Storybook addon that embed Figma, websites, PDF or images in the addon panel.
+ *
+ * Adds a "Design" tab.
+ *
+ * @see https://github.com/pocka/storybook-addon-designs
+ */
+ 'storybook-addon-designs',
+ ],
+
+ /**
+ * Customize webpack configuration for Storybook.
+ *
+ * This doesn't affect the Next.js application, only the Storybook compilation.
+ *
+ * @param config
+ * @see https://storybook.js.org/docs/react/configure/overview#configure-your-storybook-project
+ */
+ webpackFinal: async (config) => {
+ const {
+ mode: environment,
+ plugins,
+ module,
+ } = config;
+ await fetchLocizeTranslation(environment);
+
+ return {
+ ...config,
+ resolve: {
+ ...config.resolve,
+ alias: {
+ ...config.resolve.alias,
+
+ /**
+ * Map Emotion 10 libraries to Emotion 11 libraries.
+ *
+ * Otherwise Storybook fails to compile with "Module not found: Error: Can't resolve '@emotion/styled/base'", etc.
+ * It wasn't necessary to do this until we imported React component using "@emotion/styled".
+ * This issue is probably caused because Storybook uses Emotion 10 while we have Emotion 11 used by the Next.js app.
+ *
+ * @see https://github.com/storybookjs/storybook/issues/13277#issuecomment-751747964
+ */
+ '@emotion/core': toPath('node_modules/@emotion/react'),
+ '@emotion/styled': toPath('node_modules/@emotion/styled'),
+ 'emotion-theming': toPath('node_modules/@emotion/react'),
+
+ /**
+ * Map our module path aliases, so that Storybook can understand modules loaded using "@/common" and load the proper file.
+ * Required, or Storybook will fail to import dependencies from Stories.
+ *
+ * XXX The below list must match `tsconfig.json:compilerOptions.paths`, so the Next.js app and Storybook resolve all aliases the same way.
+ * The paths mapping must also match the `jsconfig.json:compilerOptions.paths` file, which is necessary for WebStorm to understand them for .js files.
+ *
+ * @see https://nextjs.org/docs/advanced-features/module-path-aliases
+ * @see https://intellij-support.jetbrains.com/hc/en-us/community/posts/360003361399/comments/360002636080
+ */
+ "@/app": path.resolve(__dirname, "../src/app"),
+ "@/common": path.resolve(__dirname, "../src/common"),
+ "@/components": path.resolve(__dirname, "../src/common/components"),
+ "@/utils": path.resolve(__dirname, "../src/common/utils"),
+ "@/layouts": path.resolve(__dirname, "../src/layouts"),
+ "@/modules": path.resolve(__dirname, "../src/modules"),
+ "@/pages": path.resolve(__dirname, "../src/pages"),
+ },
+ },
+ };
+ },
+};
diff --git a/.storybook/manager.js b/.storybook/manager.js
new file mode 100644
index 000000000..2df85ffaa
--- /dev/null
+++ b/.storybook/manager.js
@@ -0,0 +1,36 @@
+import { addons } from '@storybook/addons';
+import { themes } from '@storybook/theming';
+
+/**
+ * Configure Storybook UI layout.
+ *
+ * XXX The Storybook manager seems to suffer from a cache invalidation issue, which forces us to run with `--no-manager-cache` option.
+ * @see https://github.com/storybookjs/storybook/issues/13649#issuecomment-761076960
+ * @see https://github.com/storybookjs/storybook/issues/13200
+ *
+ * @see https://storybook.js.org/docs/react/configure/features-and-behavior
+ */
+addons.setConfig({
+ theme: themes.dark,
+});
+
+/**
+ * Your Google Analytics tracking ID.
+ *
+ * If you're creating a dedicated Google Analytics property for this (you should),
+ * Then make sure to create a "Universal Analytics property", not a Google Analytics 4 property (default since 2021).
+ *
+ * @see https://support.google.com/analytics/answer/10269537
+ * @see https://github.com/storybookjs/storybook/tree/master/addons/google-analytics Google Analytic addon for Storybook
+ */
+window.STORYBOOK_GA_ID = 'UA-89785688-10'; // Replace by your own "UA-XXXXXXX-XX"
+
+/**
+ * React-ga options object
+ *
+ * @example { debug: true, gaOptions: { userId: 123 }}
+ * @see https://github.com/react-ga/react-ga#api
+ * @see https://github.com/storybookjs/storybook/blob/4f5ab9fe9e590da7b841ec37cb1bed8d6327ea4b/addons/google-analytics/src/register.ts#L8
+ * @see https://github.com/storybookjs/storybook/tree/master/addons/google-analytics Google Analytic addon for Storybook
+ */
+window.STORYBOOK_REACT_GA_OPTIONS = {};
diff --git a/.storybook/mock/sb-dataset.js b/.storybook/mock/sb-dataset.js
new file mode 100644
index 000000000..826f344b2
--- /dev/null
+++ b/.storybook/mock/sb-dataset.js
@@ -0,0 +1,239 @@
+/**
+ * Dataset used by Storybook stories.
+ *
+ * Copied from a NRN instance "window.__CYPRESS_DATA__" and pasted there.
+ */
+const dataset = {
+ 'reci9HYsoqd1xScsi': {
+ '__typename': 'Customer',
+ 'id': 'reci9HYsoqd1xScsi',
+ 'ref': 'customer1',
+ 'label': 'Client 1',
+ 'availableLanguages': ['en', 'fr'],
+ 'theme': {
+ '__typename': 'Theme',
+ 'id': 'recrcZANU6L73OA9v',
+ 'primaryColor': '#00536F',
+ 'primaryColorVariant1': null,
+ 'onPrimaryColor': null,
+ 'secondaryColor': '#C90016',
+ 'secondaryColorVariant1': null,
+ 'onSecondaryColor': null,
+ 'backgroundColor': null,
+ 'onBackgroundColor': null,
+ 'surfaceColor': null,
+ 'onSurfaceColor': null,
+ 'errorColor': null,
+ 'onErrorColor': null,
+ 'fonts': null,
+ 'logo': {
+ 'id': 'attlGNQqFXvhDYOrR',
+ 'url': 'https://dl.airtable.com/.attachments/a16bd38af1f3fea3f894dd2a37dbf4bd/baa538c3/apple-touch-icon.png',
+ 'filename': 'apple-touch-icon.png',
+ 'size': 11769,
+ 'type': 'image/png',
+ 'thumbnails': {
+ 'small': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/c8528519fa364ebc6c01a35834a06975/1720e171',
+ 'width': 36,
+ 'height': 36,
+ },
+ 'large': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/f0f1a95475af253ef157f36faf598c99/2da93bc7',
+ 'width': 180,
+ 'height': 180,
+ },
+ 'full': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/26551c4457369ea157cab82a2ac24368/8ea162cf',
+ 'width': 3000,
+ 'height': 3000,
+ },
+ },
+ },
+ 'logoTitle': 'Awesome-looking Logo',
+ },
+ 'products': [
+ {
+ '__typename': 'Product',
+ 'id': 'recFSrY2znI6Z8Dbj',
+ 'ref': 'hellur',
+ 'title': 'Hellur',
+ 'images': [
+ {
+ 'id': 'att6JU52f5PlMuiRu',
+ 'url': 'https://dl.airtable.com/Uvg7ldEEQpqKhR3NKTGt_348s.jpg',
+ 'filename': '348s.jpg',
+ 'size': 17866,
+ 'type': 'image/jpeg',
+ 'thumbnails': {
+ 'small': {
+ 'url': 'https://dl.airtable.com/8C4cVNCES89lt6PnFH5W_348s.jpg',
+ 'width': 36,
+ 'height': 36,
+ },
+ 'large': {
+ 'url': 'https://dl.airtable.com/TdSPVnVQISc0P0EdiiQw_348s.jpg',
+ 'width': 256,
+ 'height': 256,
+ },
+ },
+ },
+ ],
+ 'imagesTitle': ['Big City'],
+ 'description': 'Super longue **description**\n\nVous pouvez même [utiliser des liens](https://bluebottlecoffee.com/releases/costa-rica-vista-al-valle-honey)\n',
+ 'price': 25,
+ 'status': 'DRAFT',
+ },
+ {
+ '__typename': 'Product',
+ 'id': 'recXxSwjiehedMFPf',
+ 'ref': 'wow',
+ 'title': 'wow',
+ 'images': [
+ {
+ 'id': 'attIQzHRvFMgmdytF',
+ 'url': 'https://dl.airtable.com/.attachments/00f7560832b1500d06d169233424ccd0/c5d69a9e/lXL1TfOBTiikTCW8DPT2',
+ 'filename': 'lXL1TfOBTiikTCW8DPT2',
+ 'size': 64273,
+ 'type': 'image/jpeg',
+ 'thumbnails': {
+ 'small': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/8e2ab049c04e918ccf068eeb2363a24d/88511a89',
+ 'width': 54,
+ 'height': 36,
+ },
+ 'large': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/6897834d90716b4221e88de8a7e5d17b/beacabda',
+ 'width': 729,
+ 'height': 486,
+ },
+ 'full': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/d10c2858cbdd091db8ea5eb283d15ff5/8a483a20',
+ 'width': 3000,
+ 'height': 3000,
+ },
+ },
+ },
+ ],
+ 'imagesTitle': ['wow'],
+ 'description': 'w\n',
+ 'price': null,
+ 'status': 'PUBLISHED',
+ },
+ ],
+ 'serviceLabel': 'NRN demo 1',
+ 'termsDescription': '\nWe use this "terms" page to showcase what\'s achievable using Markdown + HTML + JSX. \nYou can edit this through Stacker, see "Go to CMS" nav link.\n\n\n---\n\nUsing Markdown\n\nHeading 2\nHeading 3\n\nBold\n\nItalic\n\nStrikethrough\n\nLink in new tab\n\n---\n\n
Using HTML
\n\n
Heading 4
\n
Heading 5
\n
Heading 6
\n\n
Text in div
\n\nBold\n\n \n\nItalic\n\n \n\nLink (same tab)\n\n \n\nLink (new tab)\n\n---\n\nUsing JSX (React components)\n\nNote: Only a small subset of components are made available. It works based on a whitelist. You can see the full list here.\n\nComponents from Reactstrap\n\nAn Alert "info"\n\nAn Alert "success"\n\n\n\n \n \n\n\n\n \n \n\n\n
\nCol 1 in a Row (with custom CSS)\n\n
\nCol 2 in a Row\n\n\n\nCustom components\n\nHelp tooltips, using Tooltip component: \nSome complex stuff (click/hover me!)\n\n \n\nLocalised links, using I18nLink component: \nLink to homepage, keeping current locale\n\n \n\nButton to change the current locale, using I18nBtnChangeLocale component: \n\n\n\n---\n\nNote: All links always open in a new tab with "noopener" to ensure proper security defaults. This only work when used from the app (not from Stacker)\n\nNote: Stacker can preview Markdown but not HTML/JSX. The behaviour between Stacker Markdown preview and real rendering can be different.\n\n\nAs you can see above, using JSX brings quite a few interesting capabilities. But it isn\'t all-powerful though. \n\nIt\'s not possible to use JavaScript, so forget about using an onClick event for instance. You\'ll need to find workarounds for this kinds of things. \n\nAlso, it\'s not possible to provide non-scalar props. Forget about providing a component with an object, or array, for instance. \n\nNevertheless, it brings quite a few possibilities to your app. The secret is to keep things simple, using simple JSX components that rely on few props. The I18nBtnChangeLocale is a great example of that. No props, but changes the language for the whole app anyway, it\'s quite a powerful integration and very simple to use.\n\n\n',
+ 'privacyDescription': '{serviceLabel} doesn\'t track any of your personal data.\n\nAnalytic data (such as page views) are being tracked for the whole site, **anonymously**.',
+ },
+ 'recFSrY2znI6Z8Dbj': {
+ '__typename': 'Product',
+ 'id': 'recFSrY2znI6Z8Dbj',
+ 'ref': 'hellur',
+ 'title': 'Hellur',
+ 'images': [
+ {
+ 'id': 'att6JU52f5PlMuiRu',
+ 'url': 'https://dl.airtable.com/Uvg7ldEEQpqKhR3NKTGt_348s.jpg',
+ 'filename': '348s.jpg',
+ 'size': 17866,
+ 'type': 'image/jpeg',
+ 'thumbnails': {
+ 'small': {
+ 'url': 'https://dl.airtable.com/8C4cVNCES89lt6PnFH5W_348s.jpg',
+ 'width': 36,
+ 'height': 36,
+ },
+ 'large': {
+ 'url': 'https://dl.airtable.com/TdSPVnVQISc0P0EdiiQw_348s.jpg',
+ 'width': 256,
+ 'height': 256,
+ },
+ },
+ },
+ ],
+ 'imagesTitle': ['Big City'],
+ 'description': 'Super longue **description**\n\nVous pouvez même [utiliser des liens](https://bluebottlecoffee.com/releases/costa-rica-vista-al-valle-honey)\n',
+ 'price': 25,
+ 'status': 'DRAFT',
+ },
+ 'recXxSwjiehedMFPf': {
+ '__typename': 'Product',
+ 'id': 'recXxSwjiehedMFPf',
+ 'ref': 'wow',
+ 'title': 'wow',
+ 'images': [
+ {
+ 'id': 'attIQzHRvFMgmdytF',
+ 'url': 'https://dl.airtable.com/.attachments/00f7560832b1500d06d169233424ccd0/c5d69a9e/lXL1TfOBTiikTCW8DPT2',
+ 'filename': 'lXL1TfOBTiikTCW8DPT2',
+ 'size': 64273,
+ 'type': 'image/jpeg',
+ 'thumbnails': {
+ 'small': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/8e2ab049c04e918ccf068eeb2363a24d/88511a89',
+ 'width': 54,
+ 'height': 36,
+ },
+ 'large': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/6897834d90716b4221e88de8a7e5d17b/beacabda',
+ 'width': 729,
+ 'height': 486,
+ },
+ 'full': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/d10c2858cbdd091db8ea5eb283d15ff5/8a483a20',
+ 'width': 3000,
+ 'height': 3000,
+ },
+ },
+ },
+ ],
+ 'imagesTitle': ['wow'],
+ 'description': 'w\n',
+ 'price': null,
+ 'status': 'PUBLISHED',
+ },
+ 'recrcZANU6L73OA9v': {
+ '__typename': 'Theme',
+ 'id': 'recrcZANU6L73OA9v',
+ 'primaryColor': 'black',
+ 'primaryColorVariant1': null,
+ 'onPrimaryColor': null,
+ 'secondaryColor': null,
+ 'secondaryColorVariant1': null,
+ 'onSecondaryColor': null,
+ 'backgroundColor': null,
+ 'onBackgroundColor': null,
+ 'surfaceColor': null,
+ 'onSurfaceColor': null,
+ 'errorColor': null,
+ 'onErrorColor': null,
+ 'fonts': null,
+ 'logo': {
+ 'id': 'attlGNQqFXvhDYOrR',
+ 'url': 'https://dl.airtable.com/.attachments/a16bd38af1f3fea3f894dd2a37dbf4bd/baa538c3/apple-touch-icon.png',
+ 'filename': 'apple-touch-icon.png',
+ 'size': 11769,
+ 'type': 'image/png',
+ 'thumbnails': {
+ 'small': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/c8528519fa364ebc6c01a35834a06975/1720e171',
+ 'width': 36,
+ 'height': 36,
+ },
+ 'large': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/f0f1a95475af253ef157f36faf598c99/2da93bc7',
+ 'width': 180,
+ 'height': 180,
+ },
+ 'full': {
+ 'url': 'https://dl.airtable.com/.attachmentThumbnails/26551c4457369ea157cab82a2ac24368/8ea162cf',
+ 'width': 3000,
+ 'height': 3000,
+ },
+ },
+ },
+ 'logoTitle': 'Awesome-looking Logo',
+ },
+};
+
+export default dataset;
diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html
new file mode 100644
index 000000000..dbafc706d
--- /dev/null
+++ b/.storybook/preview-body.html
@@ -0,0 +1,122 @@
+
+
+
diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
new file mode 100644
index 000000000..8dafd5621
--- /dev/null
+++ b/.storybook/preview-head.html
@@ -0,0 +1,6 @@
+
+
diff --git a/.storybook/preview.js b/.storybook/preview.js
new file mode 100644
index 000000000..cd9618299
--- /dev/null
+++ b/.storybook/preview.js
@@ -0,0 +1,293 @@
+import '@/app/components/MultiversalGlobalExternalStyles'; // Import the same 3rd party libraries global styles as the pages/_app.tsx (for UI consistency)
+import MultiversalGlobalStyles from '@/app/components/MultiversalGlobalStyles';
+import '@/common/utils/ignoreNoisyWarningsHacks';
+import { getAmplitudeInstance } from '@/modules/core/amplitude/amplitudeBrowserClient';
+import amplitudeContext from '@/modules/core/amplitude/context/amplitudeContext';
+import customerContext from '@/modules/core/data/contexts/customerContext';
+import datasetContext from '@/modules/core/data/contexts/datasetContext';
+import '@/modules/core/fontAwesome/fontAwesome';
+import i18nContext from '@/modules/core/i18n/contexts/i18nContext';
+import { defaultLocale, getLangFromLocale, supportedLocales } from '@/modules/core/i18n/i18nConfig';
+import i18nextLocize from '@/modules/core/i18n/i18nextLocize';
+import previewModeContext from '@/modules/core/previewMode/contexts/previewModeContext';
+import quickPreviewContext from '@/modules/core/quickPreview/contexts/quickPreviewContext';
+import { cypressContext } from '@/modules/core/testing/contexts/cypressContext';
+import { initCustomerTheme } from '@/modules/core/theming/theme';
+import userConsentContext from '@/modules/core/userConsent/contexts/userConsentContext';
+import { userSessionContext } from '@/modules/core/userSession/userSessionContext';
+import { Amplitude, AmplitudeProvider } from '@amplitude/react-amplitude';
+import { ThemeProvider } from '@emotion/react';
+import '@storybook/addon-console'; // Automatically forwards all logs in the "Actions" panel - See https://github.com/storybookjs/storybook-addon-console
+import { withTests } from '@storybook/addon-jest';
+import { addDecorator } from '@storybook/react';
+import { themes } from '@storybook/theming';
+import find from 'lodash.find';
+import React from 'react';
+import { withNextRouter } from 'storybook-addon-next-router';
+import { withPerformance } from 'storybook-addon-performance';
+import dataset from './mock/sb-dataset';
+
+// Loads translations from local file cache (Locize)
+const i18nTranslations = require('./.sb-translations.cache.json');
+
+/**
+ * Story Global parameters for Storybook.
+ *
+ * Parameters are a set of static, named metadata about a story, typically used to control the behavior of Storybook features and addons.
+ * Parameters are applied at the top-level and act as default values.
+ *
+ * XXX They can be overridden per component and per story.
+ * See https://storybook.js.org/docs/react/writing-stories/parameters#rules-of-parameter-inheritance
+ *
+ * @see https://storybook.js.org/docs/react/writing-stories/parameters Parameters documentation
+ * @see https://github.com/storybookjs/storybook/blob/master/addons/actions/ADVANCED.md#configuration
+ * @see https://storybook.js.org/docs/react/essentials/backgrounds#configuration
+ *
+ * Theme:
+ * Configure Storybook theme, using dark by default.
+ * You can customise this behavior per story using parameters.
+ * Configuring the theme in "manager.js" didn't work out.
+ * Also, the "Docs" section is better using the "normal" theme, for readability.
+ *
+ * @see https://storybook.js.org/docs/react/configure/theming#global-theming Global theming
+ * @see https://storybook.js.org/docs/react/configure/theming#theming-docs Per story theming (parameter)
+ * @see https://storybook.js.org/docs/react/configure/theming#create-a-theme-quickstart Creating your own theme
+ */
+export const parameters = {
+ actions: {
+ argTypesRegex: '^on[A-Z].*',
+
+ /**
+ * Since Controls is built on the same engine as Storybook Docs, it can also show property documentation alongside your controls using the expanded parameter (defaults to false).
+ * We enable this for all stories by default.
+ *
+ * @see https://storybook.js.org/docs/react/essentials/controls#show-full-documentation-for-each-property
+ */
+ expanded: true,
+ },
+
+ /**
+ * Configure stories argTypes for all stories.
+ *
+ * @deprecated Should not be used at the moment. See https://github.com/storybookjs/storybook/issues/11697
+ * @see https://storybook.js.org/docs/react/essentials/controls
+ */
+ // argTypes: {},
+
+ /**
+ * Options.
+ * Couldn't find centralized documentation about it.
+ */
+ options: {
+ /**
+ * @see https://storybook.js.org/docs/react/writing-stories/naming-components-and-hierarchy#sorting-stories
+ */
+ storySort: {
+ method: 'alphabetical',
+ order: [
+ 'App', // Should be first
+ 'Next Right Now', // Should be second, if kept around
+ 'Storybook Examples', // Should be last, if kept around
+ ],
+ },
+ },
+ docs: {
+ theme: themes.normal,
+ },
+};
+
+/**
+ * Storybook ships with toolbar items to control the viewport and background the story renders in.
+ *
+ * Below, we extend the native toolbar to add a few more options, such as i18n.
+ * Those global types can then be used in decorators, for both global decorators and story decorators.
+ *
+ * @description toolbar.item Can be either an array of plain strings, or a MenuItem.
+ * See https://storybook.js.org/docs/react/essentials/toolbars-and-globals#advanced-usage
+ *
+ * @description toolbar.icon The icon the will be displayed in the top toolbar.
+ * See https://www.chromatic.com/component?appId=5a375b97f4b14f0020b0cda3&name=Basics%7CIcon&mode=interactive&buildNumber=13899
+ *
+ * @see https://storybook.js.org/docs/react/essentials/toolbars-and-globals
+ */
+export const globalTypes = {
+ locale: {
+ name: 'Locale',
+ description: 'Global locale for components',
+ defaultValue: defaultLocale,
+ toolbar: {
+ icon: 'globe', // See https://www.chromatic.com/component?appId=5a375b97f4b14f0020b0cda3&name=Basics%7CIcon&mode=interactive&buildNumber=13899
+ items: supportedLocales.map(locale => locale.name),
+ },
+ },
+};
+
+/**
+ * Allow to use Next.js Router in Storybook stories.
+ *
+ * If you need to customise a component/story, then you should see https://github.com/lifeiscontent/storybook-addon-next-router#as-a-decorator-in-a-story
+ * You'll need to specify the Router behavior per-story if the below default config doesn't suit you.
+ *
+ * @see https://github.com/lifeiscontent/storybook-addon-next-router#usage-in-previewjs
+ */
+addDecorator(
+ withNextRouter({
+ path: '/', // defaults to `/`
+ asPath: '/', // defaults to `/`
+ query: {}, // defaults to `{}`
+ // @formatter:off Disables odd WebStorm formatting for next line
+ push() {}, // defaults to using addon actions integration, can override any method in the router
+ // @formatter:on
+ }),
+);
+
+/**
+ * Decorators in .storybook/preview.js are useful to mock Stories.
+ *
+ * Like parameters, decorators can be defined globally, at the component level and for a single story (as we’ve seen).
+ * All decorators, defined at all levels that apply to a story will run whenever that story is rendered, in the order:
+ * - Global decorators, in the order they are defined
+ * - Component decorators, in the order they are defined
+ * - Story decorators, in the order they are defined.
+ *
+ * @see https://storybook.js.org/docs/react/writing-stories/decorators#context-for-mocking
+ * @see https://storybook.js.org/docs/react/writing-stories/decorators#global-decorators
+ */
+export const decorators = [
+ /**
+ * Mock variables used to initialize all stories.
+ *
+ * Mocking those ensures the components relying on them will work as expected.
+ * Basically, plays a similar role to _app and appBootstrap components (MultiversalAppBootstrap, etc.)
+ *
+ * About Amplitude analytics:
+ * - We don't want to track analytics using Amplitude.
+ * - All analytics is disabled when running a component through Storybook preview.
+ *
+ * About Google analytics, see ".storybook/main.js" documentation.
+ *
+ * @see https://storybook.js.org/docs/react/essentials/toolbars-and-globals#create-a-decorator Context and globals
+ */
+ (Story, context) => {
+ // console.log('context', context) // Prints useful information about the Story's configuration
+ // Configure i18n. In Storybook, the locale can be set from the top Toolbar.
+ const locale = context?.globals?.locale || defaultLocale;
+ const lang = getLangFromLocale(locale);
+
+ // Applies i18next configuration with Locize backend
+ // Extra features like saveMissing, etc. will be disabled in production because Storybook doesn't have access to NEXT_PUBLIC_* environment variables there
+ // Although, they are configured in the same way as the Next.js app during development mode
+ i18nextLocize(lang, i18nTranslations);
+
+ const customer = find(dataset, { __typename: 'Customer' });
+ const customerTheme = initCustomerTheme(customer);
+ const customerRef = 'storybook'; // Fake customer ref
+ const amplitudeApiKey = ''; // Use invalid amplitude tracking key to force disable all amplitude analytics
+ const userConsent = {
+ isUserOptedOutOfAnalytics: true, // Disables all amplitude analytics tracking (even if a proper api key was being used)
+ hasUserGivenAnyCookieConsent: false,
+ };
+ const userId = 'storybook'; // Fake id (would avoid user tracking even if correct api key was being used)
+ const amplitudeInstance = getAmplitudeInstance({
+ customerRef,
+ iframeReferrer: null,
+ isInIframe: false,
+ lang,
+ locale,
+ userId,
+ userConsent: userConsent,
+ });
+
+ // Configure all providers, similarly to what being done by MultiversalAppBootstrap and BrowserPageBootstrap
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+];
+
+/**
+ * Enables storybook-addon-performance for all stories by default.
+ *
+ * @see https://github.com/atlassian-labs/storybook-addon-performance#installation
+ */
+addDecorator(withPerformance);
+
+/**
+ * Configure Jest Storybook for all stories.
+ *
+ * Each story must define which test it's associated to, to show the associated tests results in the preview.
+ * See https://github.com/storybookjs/storybook/tree/master/addons/jest#usage
+ *
+ * @see https://github.com/storybookjs/storybook/tree/master/addons/jest
+ */
+try {
+ let testResults;
+ testResults = require('../.jest-test-results.json');
+
+ addDecorator(
+ withTests({
+ results: testResults,
+ }),
+ );
+} catch (e) {
+ console.log(`Couldn't find ../.jest-test-results.json, Jest tests might not work properly.`);
+}
+
diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js
new file mode 100644
index 000000000..ec60ae64b
--- /dev/null
+++ b/.storybook/webpack.config.js
@@ -0,0 +1,36 @@
+/**
+ * The doc doesn't really mention using webpack.config.js, but .storybook/main.js instead.
+ *
+ * Nevertheless, configuring the webpack.config.js seems to work fine.
+ *
+ * @param config
+ * @param mode
+ * @return {Promise<*>}
+ * @see https://storybook.js.org/docs/react/configure/webpack
+ * @see https://storybook.js.org/docs/react/configure/webpack#using-your-existing-config
+ */
+module.exports = async ({
+ config,
+ mode,
+}) => {
+ /**
+ * Fixes npm packages that depend on `fs` module, etc.
+ *
+ * E.g: "winston" would fail to load without this, because it relies on fs, which isn't available during browser build.
+ *
+ * @see https://github.com/storybookjs/storybook/issues/4082#issuecomment-495370896
+ */
+ config.node = {
+ fs: 'empty',
+ tls: 'empty',
+ net: 'empty',
+ module: 'empty',
+ console: true,
+ };
+
+ // XXX See https://github.com/vercel/next.js/blob/canary/examples/with-sentry-simple/next.config.js
+ // Because StoryBook only compiles for client and has no server runtime, we must replace backend-related libs like @sentry/node to their browser counterpart
+ config.resolve.alias['@sentry/node'] = '@sentry/browser';
+
+ return config;
+};
diff --git a/.vercelignore b/.vercelignore
new file mode 100644
index 000000000..4ace5e5de
--- /dev/null
+++ b/.vercelignore
@@ -0,0 +1,23 @@
+# Optimize speed/size of deployments by not uploading those files
+.idea/
+.github/
+node_modules/
+cypress/
+docs/
+coverage/
+scripts/
+svg-to-react/
+.codeclimate.yml
+.editorconfig
+.env*
+.graphqlconfig
+.hybrid-cache-*.cache
+.jest-test-results.json
+*.md
+LICENSE
+schema.graphql
+yarn-error.log
+
+# Avoid tests being deployed as Vercel Serverless Functions (they would increase bundle size, and count towards Vercel's limits)
+src/pages/api/*.test.ts
+src/pages/api/**/*.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a108a2b6..47486ff51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1 +1 @@
-[GO TO CHANGELOG](https://unlyed.github.io/next-right-now/CHANGELOG)
+[GO TO CHANGELOG](https://unlyed.github.io/next-right-now/changelog)
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 000000000..12d89e60c
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,24 @@
+/**
+ * Babel configuration for Next.js
+ *
+ * The official documentation uses a ".babelrc" file, but we prefer using "babel.config.js" for better documentation support.
+ *
+ * @see https://nextjs.org/docs/advanced-features/customizing-babel-config Official doc reference v10
+ * @see https://github.com/vercel/next.js/blob/canary/packages/next/build/babel/preset.ts You can take a look at this file to learn about the presets included by next/babel.
+ * @see https://emotion.sh/docs/css-prop#babel-preset Configuring Emotion 11
+ * @example https://github.com/vercel/next.js/tree/canary/examples/with-custom-babel-config Next.js official example of customizing Babel
+ */
+module.exports = {
+ presets: [
+ [
+ "next/babel",
+ {
+ "preset-react": {
+ "runtime": "automatic",
+ "importSource": "@emotion/react"
+ }
+ }
+ ]
+ ],
+ plugins: ["@emotion/babel-plugin"],
+};
diff --git a/cypress/README.md b/cypress/README.md
new file mode 100644
index 000000000..693d8bf0c
--- /dev/null
+++ b/cypress/README.md
@@ -0,0 +1,69 @@
+Cypress End-to-end tests suite (E2E)
+===
+
+This `cypress` folder is used for E2E testing.
+
+Read more about [the folder structure](https://docs.cypress.io/guides/core-concepts/writing-and-organizing-tests.html#Folder-Structure)
+
+- `_examples`: Tests created by Cypress during initial install (initially located in `integration/examples`), they've been moved to this folder so that they don't run but can still be used as learning resource
+- `integration`: The folder where tests must be written
+- `screenshot`/`videos`: Will contain assets when tests fail, useful during development. When tests are executed by Github Actions, you'll find those assets under the "Artifacts" section. (e.g: https://github.com/UnlyEd/next-right-now/runs/862302266)
+- `fixtures`: Fixtures are used as external pieces of static data that can be used by your tests. (We've kept the fixtures installed during the Cypress initial install)
+
+## Cypress config files
+
+The files `cypress/config-*` are used for different purposes.
+
+- `config-customer-ci-cd.json`: This file is a mock config file used by CI/CD GitHub Actions by the workflows `deploy-vercel-production.yml` and `deploy-vercel-staging.yml`.
+ The `baseUrl` is a fake value (required by Cypress, but not used) which is replaced at runtime by the real `baseUrl` which is a dynamic Vercel deployment url.
+- `config-development.json`: This file is only used when running `yarn e2e:run` and `yarn e2e:open` locally.
+ It uses `baseUrl=http://localhost:8888` which is where our local server is running. It's only meant for local testing
+- `config-$CUSTOMER_REF.json`: This file is only used when running `yarn deploy:$CUSTOMER_REF` locally. _It is not used by CI/CD workflows._
+
+## Tests ordering
+
+[Sanity Checks](./integration/app/_sanity/README.md) are executed first. Then, tests are executed by their folder/file alphabetical order by default.
+
+## Resources about how to write tests better
+
+- [[MUST WATCH!] Best Practices by the Author (2018) - 27mn](https://docs.cypress.io/examples/examples/tutorials.html#Best-Practices)
+- [Organize tests by type of devices (mobile/desktop)](https://docs.cypress.io/api/commands/viewport.html#Width-Height)
+- [Run tests on multiple subdomains](https://docs.cypress.io/faq/questions/using-cypress-faq.html#Can-I-run-the-same-tests-on-multiple-subdomains)
+- [Detect if Cypress is running](https://docs.cypress.io/faq/questions/using-cypress-faq.html#Is-there-any-way-to-detect-if-my-app-is-running-under-Cypress)
+- [Can my tests interact with Redux / Vuex data store? (AKA "Dynamic testing")](https://docs.cypress.io/faq/questions/using-cypress-faq.html#Can-my-tests-interact-with-Redux-Vuex-data-store)
+- [Check a custom property from the `window` object](https://docs.cypress.io/api/commands/window.html#Check-a-custom-property)
+- [Dynamic tests](https://github.com/cypress-io/cypress-example-recipes/tree/master/examples/fundamentals__dynamic-tests)
+- [Filters and data-driven tests](https://docs.cypress.io/examples/examples/tutorials.html#7-Filters-and-data-driven-tests)
+- [cypress-realworld-app](https://github.com/cypress-io/cypress-realworld-app/blob/develop/cypress/tests/ui/transaction-feeds.spec.ts)
+
+## Officiel Cypress recommandations
+
+> We see organizations starting with Cypress by placing end-to-end tests in a separate repo.
+> This is a great practice that allows someone on the team to prototype a few tests and evaluate Cypress within minutes.
+> As the time passes and the number of tests grows, we strongly suggest moving end-to-end tests to live right alongside your front end code.
+>
+> This brings many benefits:
+> - engages developers in writing end-to-end tests sooner
+> - keeps tests and the features they test in sync
+> - tests can be run every time the code changes
+> - allows code sharing between the application code and the tests (like selectors)
+
+_[Source](https://docs.cypress.io/faq/questions/using-cypress-faq.html#What-are-your-best-practices-for-organizing-tests)_
+
+[Cypress releases "Real World App" (RWA) - Blog post](https://www.cypress.io/blog/2020/06/11/introducing-the-cypress-real-world-app/)
+
+## Module path alias mapping
+
+We use module alias path mappings, to avoid using relative paths (e.g: `../../src/common`) but absolute paths (AKA "module paths") instead (e.g: `@/common`).
+
+Although it's simpler to use, it's harder to configure because it affects several configuration files:
+- The paths mapping in `tsconfig.json:compilerOptions.paths` must match those in `../tsconfig.json:compilerOptions.paths`
+- They must also match those in `jsconfig.json` file, which is necessary for WebStorm to understand them for .js files.
+
+If the module path mappings aren't properly set everywhere, it won't work.
+
+> You can still use relative paths.
+
+Reference:
+- See [Next.js "Module path aliases" documentation](https://nextjs.org/docs/advanced-features/module-path-aliases)
+- See [WebStorm issue](https://intellij-support.jetbrains.com/hc/en-us/community/posts/360003361399/comments/360002636080)
diff --git a/cypress/config-customer-ci-cd.json b/cypress/config-customer-ci-cd.json
new file mode 100644
index 000000000..2f014ed1f
--- /dev/null
+++ b/cypress/config-customer-ci-cd.json
@@ -0,0 +1,10 @@
+{
+ "//": "This file is used by CI/CD GitHub Actions (deploy-vercel-staging/production) and not meant to be used locally",
+ "baseUrl": "https://nrn-customer.vercel.app",
+ "projectId": "4dvdog",
+ "screenshotsFolder": "cypress/screenshots/customer",
+ "videosFolder": "cypress/videos/customer",
+ "env": {},
+ "ignoreTestFiles": "*.md",
+ "experimentalInteractiveRunEvents": true
+}
diff --git a/cypress/config-customer1.json b/cypress/config-customer1.json
index 76a1e8ac6..6b517ab73 100644
--- a/cypress/config-customer1.json
+++ b/cypress/config-customer1.json
@@ -1,7 +1,9 @@
{
- "baseUrl": "https://nrn-v1-ssr-mst-aptd-gcms-lcz-sty-c1.now.sh/",
+ "baseUrl": "https://nrn-v2-mst-aptd-gcms-lcz-sty-c1.vercel.app",
"projectId": "4dvdog",
"screenshotsFolder": "cypress/screenshots/customer1",
"videosFolder": "cypress/videos/customer1",
- "env": {}
+ "env": {},
+ "ignoreTestFiles": "*.md",
+ "experimentalInteractiveRunEvents": true
}
diff --git a/cypress/config-customer2.json b/cypress/config-customer2.json
index 069f85bb9..06a64a7e8 100644
--- a/cypress/config-customer2.json
+++ b/cypress/config-customer2.json
@@ -1,7 +1,9 @@
{
- "baseUrl": "https://nrn-v1-ssr-mst-aptd-gcms-lcz-sty-c2.now.sh/",
+ "baseUrl": "https://nrn-v2-mst-aptd-gcms-lcz-sty-c2.vercel.app",
"projectId": "4dvdog",
"screenshotsFolder": "cypress/screenshots/customer2",
"videosFolder": "cypress/videos/customer2",
- "env": {}
+ "env": {},
+ "ignoreTestFiles": "*.md",
+ "experimentalInteractiveRunEvents": true
}
diff --git a/cypress/config-development.json b/cypress/config-development.json
index b265a4d74..294c735d9 100644
--- a/cypress/config-development.json
+++ b/cypress/config-development.json
@@ -3,5 +3,7 @@
"projectId": "4dvdog",
"screenshotsFolder": "cypress/screenshots/development",
"videosFolder": "cypress/videos/development",
- "env": {}
+ "env": {},
+ "ignoreTestFiles": "*.md",
+ "experimentalInteractiveRunEvents": true
}
diff --git a/cypress/config-storybook.json b/cypress/config-storybook.json
new file mode 100644
index 000000000..8a9b72a79
--- /dev/null
+++ b/cypress/config-storybook.json
@@ -0,0 +1,11 @@
+{
+ "//": "This file is used by CI/CD GitHub Actions (deploy-vercel-storybook) and not meant to be used locally",
+ "baseUrl": "http://localhost:6006",
+ "projectId": "4dvdog",
+ "screenshotsFolder": "cypress/screenshots/storybook",
+ "videosFolder": "cypress/videos/storybook",
+ "integrationFolder": "cypress/integration-storybook",
+ "env": {},
+ "ignoreTestFiles": "*.md",
+ "experimentalInteractiveRunEvents": true
+}
diff --git a/cypress/global.d.ts b/cypress/global.d.ts
new file mode 100644
index 000000000..6d4cbd5a6
--- /dev/null
+++ b/cypress/global.d.ts
@@ -0,0 +1 @@
+///
diff --git a/cypress/integration-storybook/app/_sanity/1-domain.ts b/cypress/integration-storybook/app/_sanity/1-domain.ts
new file mode 100644
index 000000000..51947c49b
--- /dev/null
+++ b/cypress/integration-storybook/app/_sanity/1-domain.ts
@@ -0,0 +1,22 @@
+const baseUrl = Cypress.config().baseUrl;
+
+describe('Sanity checks > Domain', () => {
+ /*
+ * Visits the home page before any test
+ */
+ before(() => {
+ cy.visit('/');
+ });
+
+ it(`should be running on the domain "${baseUrl}"`, () => {
+ cy.url().then((url) => {
+ cy.log(`Expected to be running on:`);
+ cy.log(baseUrl);
+ cy.log(`Actually running at:`);
+ cy.log(url);
+ cy.url({ timeout: 300000 }).should('contains', baseUrl); // Wait at least 5 minute before timing out
+ });
+ });
+});
+
+export {};
diff --git a/cypress/integration-storybook/app/_sanity/README.md b/cypress/integration-storybook/app/_sanity/README.md
new file mode 100644
index 000000000..77784d7b8
--- /dev/null
+++ b/cypress/integration-storybook/app/_sanity/README.md
@@ -0,0 +1,12 @@
+Sanity checks
+===
+
+The purpose of this folder is to contain sanity tests that should be executed before the others.
+We use it to make sure we're on the right domain before running our tests, because it can happen E2E test start before Vercel if fully configured, and thus the tests would run on the wrong page.
+To avoid this to happen, we first wait up to 5mn before timing out when checking the domain name. This ensures other tests don't run on an unexpected domain and fail, which would be misleading.
+
+This way, the first tests ensure we're running in the expected environment and will fail if we don't, which helps understand the actual issue.
+
+Read more [Ability to run spec files in a specific order](https://github.com/cypress-io/cypress/issues/390)
+
+Work around to order your tests: [Cypress - How can I run test files in order](https://stackoverflow.com/questions/58936891/cypress-how-can-i-run-test-files-in-order/59690611#59690611)
diff --git a/cypress/integration-storybook/app/stories/dataDisplay/btn.ts b/cypress/integration-storybook/app/stories/dataDisplay/btn.ts
new file mode 100644
index 000000000..0aca78e43
--- /dev/null
+++ b/cypress/integration-storybook/app/stories/dataDisplay/btn.ts
@@ -0,0 +1,30 @@
+describe('Btn story', () => {
+ /**
+ * Visits the story page before any test.
+ */
+ before(() => {
+ cy.visit('/?path=/story/next-right-now-welcome-to-nrn--page');
+ });
+
+ /**
+ * XXX The role of this test is to make sure to detect regressions affecting the whole Storybook site.
+ * We don't intend to test all components here, we only want to be warned if we ever break Storybook.
+ */
+ it('should have a "Data display" menu on the left navigation panel', () => {
+ cy.get('#next-right-now-data-display').should('have.length', 1).should('have.text', 'Data display').click();
+ });
+
+ it('should have a "Data display" > "Btn" story', () => {
+ cy.get('#next-right-now-data-display-btn').should('have.length', 1).should('have.text', 'Btn').click();
+ });
+
+ it('should have a writable "#text" control property', () => {
+ cy.get('#text').should('have.length', 1).should('have.text', 'Hello').type(' Cypress!');
+ });
+
+ it('should have changed the Btn text to "Hello Cypress!', () => {
+ cy.findIframe('iframe#storybook-preview-iframe').find('#root button').should('have.text', 'Hello Cypress!')
+ });
+});
+
+export {};
diff --git a/cypress/integration/app/_sanity/1-domain.ts b/cypress/integration/app/_sanity/1-domain.ts
new file mode 100644
index 000000000..f2933e704
--- /dev/null
+++ b/cypress/integration/app/_sanity/1-domain.ts
@@ -0,0 +1,26 @@
+const baseUrl = Cypress.config().baseUrl;
+
+describe('Sanity checks > Domain', {
+ // retries: {
+ // runMode: 2, // Allows 2 retries (for a total of 3 attempts) to reduce the probability of failing the whole tests suite because Vercel hasn't finished to deploy yet (which makes Cypress fail by trying to test the Vercel "waiting page", instead of our app)
+ // }
+}, () => {
+ /*
+ * Visits the home page before any test
+ */
+ before(() => {
+ cy.visit('/en');
+ });
+
+ it(`should be running on the domain "${baseUrl}"`, () => {
+ cy.url().then((url) => {
+ cy.log(`Expected to be running on:`);
+ cy.log(baseUrl);
+ cy.log(`Actually running at:`);
+ cy.log(url);
+ cy.url({ timeout: 300000 }).should('contains', baseUrl); // Wait at least 5 minute before timing out
+ });
+ });
+});
+
+export {};
diff --git a/cypress/integration/app/_sanity/2-customer.ts b/cypress/integration/app/_sanity/2-customer.ts
new file mode 100644
index 000000000..5cfb7022f
--- /dev/null
+++ b/cypress/integration/app/_sanity/2-customer.ts
@@ -0,0 +1,31 @@
+import { Customer } from '@/modules/core/data/types/Customer';
+import { CYPRESS_WINDOW_NS } from '@/modules/core/testing/cypress';
+
+describe('Sanity checks > Browser data', () => {
+ /**
+ * Visits the home page before any test.
+ */
+ before(() => {
+ cy.visit('/en');
+ });
+
+ /**
+ * Prepare aliases before each test. (they're destroyed at the end of each test)
+ */
+ beforeEach(() => {
+ cy.prepareDOMAliases();
+ });
+
+ it(`should have "window.${CYPRESS_WINDOW_NS}.dataset" defined`, () => {
+ cy.get('@dataset').then((dataset) => {
+ assert.isDefined(dataset);
+ expect(Object.keys(dataset).length).to.be.greaterThan(0);
+ });
+ });
+
+ it(`should have "window.${CYPRESS_WINDOW_NS}.customer" defined`, () => {
+ cy.get('@customer').then((customer: Customer) => {
+ assert.isDefined(customer.label);
+ });
+ });
+});
diff --git a/cypress/integration/app/_sanity/README.md b/cypress/integration/app/_sanity/README.md
new file mode 100644
index 000000000..77784d7b8
--- /dev/null
+++ b/cypress/integration/app/_sanity/README.md
@@ -0,0 +1,12 @@
+Sanity checks
+===
+
+The purpose of this folder is to contain sanity tests that should be executed before the others.
+We use it to make sure we're on the right domain before running our tests, because it can happen E2E test start before Vercel if fully configured, and thus the tests would run on the wrong page.
+To avoid this to happen, we first wait up to 5mn before timing out when checking the domain name. This ensures other tests don't run on an unexpected domain and fail, which would be misleading.
+
+This way, the first tests ensure we're running in the expected environment and will fail if we don't, which helps understand the actual issue.
+
+Read more [Ability to run spec files in a specific order](https://github.com/cypress-io/cypress/issues/390)
+
+Work around to order your tests: [Cypress - How can I run test files in order](https://stackoverflow.com/questions/58936891/cypress-how-can-i-run-test-files-in-order/59690611#59690611)
diff --git a/cypress/integration/app/common/footer.ts b/cypress/integration/app/common/footer.ts
new file mode 100644
index 000000000..598439487
--- /dev/null
+++ b/cypress/integration/app/common/footer.ts
@@ -0,0 +1,49 @@
+import { Customer } from '@/modules/core/data/types/Customer';
+
+const baseUrl = Cypress.config().baseUrl;
+
+describe('Common > Footer section', () => {
+ /**
+ * Visits the home page before any test.
+ */
+ before(() => {
+ cy.visit('/en');
+ });
+
+ /**
+ * Prepare aliases before each test. (they're destroyed at the end of each test)
+ */
+ beforeEach(() => {
+ cy.prepareDOMAliases();
+ });
+
+ it('should have the Unly logo in the footer', () => {
+ cy.get('#footer-logo-unly-brand').should('have.length', 1);
+ });
+
+ // Disabled because footer logo can be removed by anyone on the public demo and this makes tests crash for no valid reason (annoying)
+ // it('should have the customer logo in the footer', () => {
+ // cy.get('#footer-logo').should('have.length', 1);
+ // });
+
+ it('should display the i18n button to change language', () => {
+ cy.get('@customer').then((customer: Customer) => {
+ const availableLanguagesCount = 2;
+ cy.log(`Available language(s): ${availableLanguagesCount}`);
+
+ if (availableLanguagesCount > 1) {
+ it('should have a button to change the language which changes the language upon click', () => {
+ cy.get('#footer-btn-change-locale').should('have.length', 1).click({ force: true });
+ cy.url().should('eq', `${baseUrl}/fr`);
+ });
+ } else {
+ it('should not have a button to change the language', () => {
+ cy.get('#footer-btn-change-locale').should('not.have.length', 1);
+ });
+ }
+ });
+ });
+
+});
+
+export {};
diff --git a/cypress/integration/app/common/nav.ts b/cypress/integration/app/common/nav.ts
new file mode 100644
index 000000000..abe668b61
--- /dev/null
+++ b/cypress/integration/app/common/nav.ts
@@ -0,0 +1,35 @@
+import { Customer } from '@/modules/core/data/types/Customer';
+
+const baseUrl = Cypress.config().baseUrl;
+
+describe('Common > Nav section', () => {
+ /**
+ * Visits the home page before any test.
+ */
+ before(() => {
+ cy.visit('/en');
+ });
+
+ /**
+ * Prepare aliases before each test. (they're destroyed at the end of each test)
+ */
+ beforeEach(() => {
+ cy.prepareDOMAliases();
+ });
+
+ it('should have 3 links in the navigation bar', () => {
+ cy.get('#nav .navbar-nav > .nav-item').should('have.length', 5);
+ });
+
+ it('should have a link in the navbar that redirects to the home page', () => {
+ cy.get('@customer').then((customer: Customer) => {
+ const isPageInEnglish = true;
+ cy.get('#nav-link-home')
+ .should('have.text', isPageInEnglish ? 'Home' : 'Accueil')
+ .click();
+ cy.url({ timeout: 10000 }).should('eq', `${baseUrl}/${isPageInEnglish ? 'en' : 'fr'}`);
+ });
+ });
+});
+
+export {};
diff --git a/cypress/integration/app/pages/index.js b/cypress/integration/app/pages/index.js
deleted file mode 100644
index b20f25b23..000000000
--- a/cypress/integration/app/pages/index.js
+++ /dev/null
@@ -1,36 +0,0 @@
-const baseUrl = Cypress.config().baseUrl;
-
-describe('Index page', () => {
- /*
- * Visits the page before each test
- */
- beforeEach(() => {
- cy.visit('/');
- });
-
- /**
- * Footer section
- */
- it('should have the Unly logo in the footer', () => {
- cy.get('#footer-logo-unly-brand').should('have.length', 1);
- });
-
- it('should have the customer logo in the footer', () => {
- cy.get('#footer-logo-organisation-brand').should('have.length', 1);
- });
-
- /**
- * Navbar section
- */
- it('should have 5 links in the navigation bar', () => {
- cy.get('#nav a.nav-link').should('have.length', 5);
- });
-
- it('should have a link in the navbar that redirects to the examples page', () => {
- cy.url().should('eq', `${baseUrl}/`);
- cy.get('#nav-link-examples')
- .should('have.text', 'Examples')
- .click();
- cy.url().should('eq', `${baseUrl}/examples`);
- });
-});
diff --git a/cypress/integration/app/pages/index.ts b/cypress/integration/app/pages/index.ts
new file mode 100644
index 000000000..df098f959
--- /dev/null
+++ b/cypress/integration/app/pages/index.ts
@@ -0,0 +1,23 @@
+const baseUrl = Cypress.config().baseUrl;
+
+describe('Index page', () => {
+ /**
+ * Visits the home page before any test.
+ */
+ before(() => {
+ cy.visit('/en');
+ });
+
+ /**
+ * Prepare aliases before each test. (they're destroyed at the end of each test)
+ */
+ beforeEach(() => {
+ cy.prepareDOMAliases();
+ });
+
+ it('should display a main title', () => {
+ cy.get('h1').should('have.length', 1).should('have.text', 'Next Right Now Demo');
+ });
+});
+
+export {};
diff --git a/cypress/jsconfig.json b/cypress/jsconfig.json
new file mode 100644
index 000000000..84c874b98
--- /dev/null
+++ b/cypress/jsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/app/*": [
+ "../src/app/*"
+ ],
+ "@/common/*": [
+ "../src/common/*"
+ ],
+ "@/components/*": [
+ "../src/common/components/*"
+ ],
+ "@/utils/*": [
+ "../src/common/utils/*"
+ ],
+ "@/layouts/*": [
+ "../src/layouts/*"
+ ],
+ "@/modules/*": [
+ "../src/modules/*"
+ ],
+ "@/pages/*": [
+ "../src/pages/*"
+ ]
+ }
+ }
+}
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
index fd170fba6..7d9ae3d2c 100644
--- a/cypress/plugins/index.js
+++ b/cypress/plugins/index.js
@@ -11,7 +11,10 @@
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
+///
+
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
+ return config;
}
diff --git a/cypress/support/commands.d.ts b/cypress/support/commands.d.ts
new file mode 100644
index 000000000..66c0bfe79
--- /dev/null
+++ b/cypress/support/commands.d.ts
@@ -0,0 +1,6 @@
+declare namespace Cypress {
+ interface cy extends Chainable {
+ prepareDOMAliases: () => Chainable;
+ findIframe: (iframeSelector: string) => Chainable;
+ }
+}
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index ca4d256f3..ba42fe337 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -7,19 +7,49 @@
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
-//
-//
-// -- This is a parent command --
-// Cypress.Commands.add("login", (email, password) => { ... })
-//
-//
-// -- This is a child command --
-// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
-//
-//
-// -- This is a dual command --
-// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
-//
-//
-// -- This will overwrite an existing command --
-// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
+
+import { CYPRESS_WINDOW_NS } from '@/modules/core/testing/cypress';
+
+/**
+ * Prepare DOM aliases by fetching the customer data from the browser window and aliasing them for later use.
+ *
+ * @example cy.prepareDOMAliases();
+ */
+Cypress.Commands.add('prepareDOMAliases', () => {
+ return cy.window().then((window) => {
+ cy.get('.page-wrapper').then(() => { // Wait for the DOM element to be created by Next.js before trying to read any dynamic data from the "window" object
+ cy.log(`window[${CYPRESS_WINDOW_NS}]`, window[CYPRESS_WINDOW_NS]);
+
+ const {
+ customer,
+ dataset,
+ } = window[CYPRESS_WINDOW_NS];
+
+ // Use aliases to make our variables reusable across tests - See https://docs.cypress.io/guides/core-concepts/variables-and-aliases.html#Sharing-Context
+ cy.wrap(customer).as('customer');
+ cy.wrap(dataset).as('dataset');
+ });
+ });
+});
+
+/**
+ * Finds an iframe and returns it "body" HTML element.
+ *
+ * XXX Alternatively, look for the cypress-iframe NPM plugin if you need more iframe-related features! See https://www.npmjs.com/package/cypress-iframe
+ *
+ * @example cy.findIframe();
+ * @see https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress/
+ */
+Cypress.Commands.add('findIframe', (iframeSelector) => {
+ // get the iframe > document > body
+ // and retry until the body element is not empty
+ cy.log('findIframe')
+
+ return cy
+ .get(iframeSelector, { log: false })
+ .its('0.contentDocument.body', { log: false }).should('not.be.empty')
+ // wraps "body" DOM element to allow
+ // chaining more Cypress commands, like ".find(...)"
+ // https://on.cypress.io/wrap
+ .then((body) => cy.wrap(body, { log: false }))
+})
diff --git a/cypress/support/index.js b/cypress/support/index.js
index d68db96df..7e5e802e9 100644
--- a/cypress/support/index.js
+++ b/cypress/support/index.js
@@ -13,8 +13,13 @@
// https://on.cypress.io/configuration
// ***********************************************************
-// Import commands.js using ES2015 syntax:
-import './commands'
+// See https://dev.to/cuichenli/how-do-i-setup-my-nextjs-development-environment-2kao
+import './commands';
-// Alternatively you can use CommonJS syntax:
-// require('./commands')
+// See https://docs.cypress.io/api/events/catalog-of-events.html#Uncaught-Exceptions
+Cypress.on('uncaught:exception', (err, runnable) => {
+ // returning false here prevents Cypress from
+ // failing the test
+ console.error('Application error caught:', err, runnable);
+ return false;
+});
diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json
new file mode 100644
index 000000000..509f3f67a
--- /dev/null
+++ b/cypress/tsconfig.json
@@ -0,0 +1,38 @@
+{
+ "extends": "../tsconfig.json",
+ "include": [
+ "./**/*.ts*"
+ ],
+ "exclude": [],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/app/*": [
+ "../src/app/*"
+ ],
+ "@/common/*": [
+ "../src/common/*"
+ ],
+ "@/components/*": [
+ "../src/common/components/*"
+ ],
+ "@/utils/*": [
+ "../src/common/utils/*"
+ ],
+ "@/layouts/*": [
+ "../src/layouts/*"
+ ],
+ "@/modules/*": [
+ "../src/modules/*"
+ ],
+ "@/pages/*": [
+ "../src/pages/*"
+ ]
+ },
+ "types": [
+ "cypress"
+ ],
+ "sourceMap": false,
+ "isolatedModules": true
+ }
+}
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 000000000..45c150536
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,3 @@
+_site
+.sass-cache
+.jekyll-metadata
diff --git a/docs/404.html b/docs/404.html
new file mode 100644
index 000000000..eeaa17753
--- /dev/null
+++ b/docs/404.html
@@ -0,0 +1,25 @@
+---
+layout: default
+nav_exclude: true
+---
+
+
+
+
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 000000000..9cb629a09
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,75 @@
+---
+layout: default
+title: CONTRIBUTING
+nav_order: 90
+---
+
+# Contributing
+{: .no_toc }
+
+{% include page-toc.md %}
+
+---
+
+## Contributing about documentation
+
+Our documentation lives in the `docs/` folder. It is generated and hosted by Github Pages.
+
+Only the `master` branch generates the online documentation.
+
+It uses [Jekyll](https://jekyllrb.com/) behind the wheel, and [`just-the-docs`](https://pmarsceill.github.io/just-the-docs/) theme for Jekyll.
+
+---
+
+### Installing Jekyll locally
+
+In order to contribute to the docs, you may need to install Jekyll locally (especially for non-trivial changes).
+Jekyll needs Ruby binary.
+
+1. Install and configure Jekyll on your computer, follow [https://jekyllrb.com/docs/](https://jekyllrb.com/docs/)
+1. Once Jekyll is installed, you can install all Ruby gems using `yarn doc:gem:install`
+1. Once gems are installed, you can run the local Jekyll server by using `yarn doc:start` which will start the server at localhost:4000
+
+---
+
+### Configuring Jekyll properly
+
+Jekyll configuration uses 2 different files.
+- [`docs/_config.yml`](docs/_config.yml) used by Github Pages
+- [`docs/_config-development.yml`](docs/_config-development.yml) used by your local installation
+
+There are a few, but important differences between both. The custom configuration must be written at the top of each config file.
+The shared configuration must be written below.
+
+> **N.B**: If you add custom/shared configuration, don't forget update both config files, as needed.
+
+---
+
+### Reference
+
+Jekyll theme used: [`just-the-docs`](https://pmarsceill.github.io/just-the-docs/)
+
+#### How to build a custom TOC
+
+See [just-the-docs documentation](https://pmarsceill.github.io/just-the-docs/docs/navigation-structure/#in-page-navigation-with-table-of-contents)
+
+#### How to write comments in Markdown files
+
+```md
+[//]: # (Some markdown comment)
+```
+
+---
+
+### Known issues
+
+- Using `yarn doc:start` will rebuild the whole documentation but it's slower. Using `yarn doc:start:fast` won't rebuild the whole thing and it's faster.
+ If you're working on the navigation menu, be warned the fast mode won't apply changes and your menu links won't update.
+
+---
+
+
+
+ [CHANGELOG](./changelog){: .btn .btn }
+
+
diff --git a/docs/favicon.ico b/docs/favicon.ico
new file mode 100644
index 000000000..8a258869e
Binary files /dev/null and b/docs/favicon.ico differ
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 000000000..1fcd15110
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,32 @@
+---
+layout: default
+title: Introduction
+nav_order: 10
+---
+
+# My GitHub Pages docs site
+
+Hello! Welcome to your docs site that comes built-in with NRN :smiley:
+
+Take a look at the [CONTRIBUTING](./contributing) section about how to install/run those docs locally.
+
+All the documentation you need about how to build your own docs site is available at [`just-the-docs`](https://pmarsceill.github.io/just-the-docs/).
+
+If you want to look at a more complex example, take a look at the [NRN docs source code](https://github.com/UnlyEd/next-right-now/tree/gh-pages)!
+
+One small difference though, NRN docs lives at the / folder, because it uses gh-pages, but the built-in docs that comes with a NRN preset are in the `docs/` folder instead.
+
+Using a `gh-pages` or `master` dedicated branch, or using your `master` branch `docs` folder really is up to you, you can choose what option you want on your GitHub Settings pages, in the **GitHub Pages** section.
+
+By default, GitHub won't use your `/docs` folder, until you manually configure it.
+
+> You can definitely leave this folder be for now on, and keep it around for a later use, or remove it altogether.
+> It's completely unrelated to the rest of this boilerplate and won't have any side effect.
+
+---
+
+
'),c=document.getElementById("buttonCloseUpdateBrowser"),w=document.getElementById("buttonUpdateBrowser"),i.style.backgroundColor=t,i.style.color=r,i.children[0].children[0].style.color=r,i.children[0].children[1].style.color=r,w&&(w.style.color=r,w.style.borderColor&&(w.style.borderColor=r),w.onmouseover=function(){this.style.color=t,this.style.backgroundColor=r},w.onmouseout=function(){this.style.color=r,this.style.backgroundColor=t}),c.style.color=r,c.onmousedown=function(){return!(i.style.display="none")}}},o=window.onload;"function"!=typeof window.onload?window.onload=e:window.onload=function(){o&&o(),e()}}},{"./extend":1,"./languages.json":3,"ua-parser-js":4}],3:[function(e,o,i){o.exports={br:{outOfDate:"O seu navegador está desatualizado!",update:{web:"Atualize o seu navegador para ter uma melhor experiência e visualização deste site. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#br",callToAction:"Atualize o seu navegador agora",close:"Fechar"},ca:{outOfDate:"El vostre navegador no està actualitzat!",update:{web:"Actualitzeu el vostre navegador per veure correctament aquest lloc web. ",googlePlay:"Instal·leu Chrome des de Google Play",appStore:"Actualitzeu iOS des de l'aplicació Configuració"},url:"https://browser-update.org/update-browser.html#es",callToAction:"Actualitzar el meu navegador ara",close:"Tancar"},cn:{outOfDate:"您的浏览器已过时",update:{web:"要正常浏览本网站请升级您的浏览器。",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#cn",callToAction:"现在升级",close:"关闭"},cz:{outOfDate:"Váš prohlížeč je zastaralý!",update:{web:"Pro správné zobrazení těchto stránek aktualizujte svůj prohlížeč. ",googlePlay:"Nainstalujte si Chrome z Google Play",appStore:"Aktualizujte si systém iOS"},url:"https://browser-update.org/update-browser.html#cz",callToAction:"Aktualizovat nyní svůj prohlížeč",close:"Zavřít"},da:{outOfDate:"Din browser er forældet!",update:{web:"Opdatér din browser for at få vist denne hjemmeside korrekt. ",googlePlay:"Installér venligst Chrome fra Google Play",appStore:"Opdatér venligst iOS"},url:"https://browser-update.org/update-browser.html#da",callToAction:"Opdatér din browser nu",close:"Luk"},de:{outOfDate:"Ihr Browser ist veraltet!",update:{web:"Bitte aktualisieren Sie Ihren Browser, um diese Website korrekt darzustellen. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#de",callToAction:"Den Browser jetzt aktualisieren ",close:"Schließen"},ee:{outOfDate:"Sinu veebilehitseja on vananenud!",update:{web:"Palun uuenda oma veebilehitsejat, et näha lehekülge korrektselt. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#ee",callToAction:"Uuenda oma veebilehitsejat kohe",close:"Sulge"},en:{outOfDate:"Your browser is out-of-date!",update:{web:"Update your browser to view this website correctly. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#",callToAction:"Update my browser now",close:"Close"},es:{outOfDate:"¡Tu navegador está anticuado!",update:{web:"Actualiza tu navegador para ver esta página correctamente. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#es",callToAction:"Actualizar mi navegador ahora",close:"Cerrar"},fa:{rightToLeft:!0,outOfDate:"مرورگر شما منسوخ شده است!",update:{web:"جهت مشاهده صحیح این وبسایت، مرورگرتان را بروز رسانی نمایید. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#",callToAction:"همین حالا مرورگرم را بروز کن",close:"Close"},fi:{outOfDate:"Selaimesi on vanhentunut!",update:{web:"Lataa ajantasainen selain nähdäksesi tämän sivun oikein. ",googlePlay:"Asenna uusin Chrome Google Play -kaupasta",appStore:"Päivitä iOS puhelimesi asetuksista"},url:"https://browser-update.org/update-browser.html#fi",callToAction:"Päivitä selaimeni nyt ",close:"Sulje"},fr:{outOfDate:"Votre navigateur n'est plus compatible !",update:{web:"Mettez à jour votre navigateur pour afficher correctement ce site Web. ",googlePlay:"Merci d'installer Chrome depuis le Google Play Store",appStore:"Merci de mettre à jour iOS depuis l'application Réglages"},url:"https://browser-update.org/update-browser.html#fr",callToAction:"Mettre à jour maintenant ",close:"Fermer"},hu:{outOfDate:"A böngészője elavult!",update:{web:"Firssítse vagy cserélje le a böngészőjét. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#hu",callToAction:"A böngészőm frissítése ",close:"Close"},id:{outOfDate:"Browser yang Anda gunakan sudah ketinggalan zaman!",update:{web:"Perbaharuilah browser Anda agar bisa menjelajahi website ini dengan nyaman. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#",callToAction:"Perbaharui browser sekarang ",close:"Close"},it:{outOfDate:"Il tuo browser non è aggiornato!",update:{web:"Aggiornalo per vedere questo sito correttamente. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#it",callToAction:"Aggiorna ora",close:"Chiudi"},lt:{outOfDate:"Jūsų naršyklės versija yra pasenusi!",update:{web:"Atnaujinkite savo naršyklę, kad galėtumėte peržiūrėti šią svetainę tinkamai. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#",callToAction:"Atnaujinti naršyklę ",close:"Close"},nl:{outOfDate:"Je gebruikt een oude browser!",update:{web:"Update je browser om deze website correct te bekijken. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#nl",callToAction:"Update mijn browser nu ",close:"Sluiten"},pl:{outOfDate:"Twoja przeglądarka jest przestarzała!",update:{web:"Zaktualizuj swoją przeglądarkę, aby poprawnie wyświetlić tę stronę. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#pl",callToAction:"Zaktualizuj przeglądarkę już teraz",close:"Close"},pt:{outOfDate:"O seu browser está desatualizado!",update:{web:"Atualize o seu browser para ter uma melhor experiência e visualização deste site. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#pt",callToAction:"Atualize o seu browser agora",close:"Fechar"},ro:{outOfDate:"Browserul este învechit!",update:{web:"Actualizați browserul pentru a vizualiza corect acest site. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#",callToAction:"Actualizați browserul acum!",close:"Close"},ru:{outOfDate:"Ваш браузер устарел!",update:{web:"Обновите ваш браузер для правильного отображения этого сайта. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#ru",callToAction:"Обновить мой браузер ",close:"Закрыть"},si:{outOfDate:"Vaš brskalnik je zastarel!",update:{web:"Za pravilen prikaz spletne strani posodobite vaš brskalnik. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#si",callToAction:"Posodobi brskalnik ",close:"Zapri"},sv:{outOfDate:"Din webbläsare stödjs ej längre!",update:{web:"Uppdatera din webbläsare för att webbplatsen ska visas korrekt. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#",callToAction:"Uppdatera min webbläsare nu",close:"Stäng"},ua:{outOfDate:"Ваш браузер застарів!",update:{web:"Оновіть ваш браузер для правильного відображення цього сайта. ",googlePlay:"Please install Chrome from Google Play",appStore:"Please update iOS from the Settings App"},url:"https://browser-update.org/update-browser.html#ua",callToAction:"Оновити мій браузер ",close:"Закрити"}}},{}],4:[function(e,k,P){!function(t,p){"use strict";var c="function",e="undefined",o="model",i="name",a="type",r="vendor",s="version",l="architecture",n="console",d="mobile",u="tablet",w="smarttv",m="wearable",g={extend:function(e,o){var i={};for(var a in e)o[a]&&o[a].length%2==0?i[a]=o[a].concat(e[a]):i[a]=e[a];return i},has:function(e,o){return"string"==typeof e&&-1!==o.toLowerCase().indexOf(e.toLowerCase())},lowerize:function(e){return e.toLowerCase()},major:function(e){return"string"==typeof e?e.replace(/[^\d\.]/g,"").split(".")[0]:p},trim:function(e){return e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")}},b={rgx:function(e,o){for(var i,a,t,r,s,l,n=0;n&1 | sed '/^* /d; /bytes data]$/d; s/> //; s/< //'
+
+# See https://stackoverflow.com/a/54225157/2391795
+# -vs - add headers (-v) but remove progress bar (-s)
+# 2>&1 - combine stdout and stderr into single stdout
+# sed - edit response produced by curl using the commands below
+# /^* /d - remove lines starting with '* ' (technical info)
+# /bytes data]$/d - remove lines ending with 'bytes data]' (technical info)
+# s/> // - remove '> ' prefix
+# s/< // - remove '< ' prefix
diff --git a/src/README.md b/src/README.md
new file mode 100644
index 000000000..6bf664c35
--- /dev/null
+++ b/src/README.md
@@ -0,0 +1,6 @@
+Sources folder
+===
+
+[Read more about the folder structure](https://unlyed.github.io/next-right-now/reference/folder-structure).
+
+Also, you might be interested to understand how "[module path aliases](https://unlyed.github.io/next-right-now/reference/module-path-aliases)" work.
diff --git a/src/app/README.md b/src/app/README.md
new file mode 100644
index 000000000..92d3b3d2c
--- /dev/null
+++ b/src/app/README.md
@@ -0,0 +1,11 @@
+App
+===
+
+> Check out the [documentation about the folder structure](../README.md#app-folder)
+
+Summary:
+
+- This folder is a way to organize what happens in `pages/_app`.
+- It also contains app-wide configuration (constants).
+- `Multiversal` means code executed "no matter what".
+- Next Right Now `pages/_app` split the server and the browser code into 2 different files, to make it easier to understand.
diff --git a/src/app/components/BrowserPageBootstrap.tsx b/src/app/components/BrowserPageBootstrap.tsx
new file mode 100644
index 000000000..ad3f416cd
--- /dev/null
+++ b/src/app/components/BrowserPageBootstrap.tsx
@@ -0,0 +1,217 @@
+import { MultiversalPageProps } from '@/layouts/core/types/MultiversalPageProps';
+import { OnlyBrowserPageProps } from '@/layouts/core/types/OnlyBrowserPageProps';
+import { getAmplitudeInstance } from '@/modules/core/amplitude/amplitudeBrowserClient';
+import amplitudeContext from '@/modules/core/amplitude/context/amplitudeContext';
+import UniversalCookiesManager from '@/modules/core/cookiesManager/UniversalCookiesManager';
+import useCustomer from '@/modules/core/data/hooks/useCustomer';
+import useDataset from '@/modules/core/data/hooks/useDataset';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { detectLightHouse } from '@/modules/core/lightHouse/lighthouse';
+import { createLogger } from '@/modules/core/logging/logger';
+import {
+ ClientNetworkConnectionType,
+ ClientNetworkInformationSpeed,
+ getClientNetworkConnectionType,
+ getClientNetworkInformationSpeed,
+} from '@/modules/core/networkInformation/networkInformation';
+import { configureSentryBrowserMetadata } from '@/modules/core/sentry/browser';
+import { configureSentryUserMetadata } from '@/modules/core/sentry/universal';
+import { cypressContext } from '@/modules/core/testing/contexts/cypressContext';
+import {
+ CYPRESS_WINDOW_NS,
+ detectCypress,
+} from '@/modules/core/testing/cypress';
+import userConsentContext from '@/modules/core/userConsent/contexts/userConsentContext';
+import initCookieConsent, { getUserConsent } from '@/modules/core/userConsent/cookieConsent';
+import { UserConsent } from '@/modules/core/userConsent/types/UserConsent';
+import { UserSemiPersistentSession } from '@/modules/core/userSession/types/UserSemiPersistentSession';
+import { userSessionContext } from '@/modules/core/userSession/userSessionContext';
+import {
+ getIframeReferrer,
+ isRunningInIframe,
+} from '@/utils/iframe';
+import {
+ Amplitude,
+ AmplitudeProvider,
+} from '@amplitude/react-amplitude';
+import { useTheme } from '@emotion/react';
+import * as Sentry from '@sentry/node';
+import { AmplitudeClient } from 'amplitude-js';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { MultiversalAppBootstrapPageProps } from '../types/MultiversalAppBootstrapPageProps';
+import { MultiversalAppBootstrapProps } from '../types/MultiversalAppBootstrapProps';
+
+const fileLabel = 'app/components/BrowserPageBootstrap';
+const logger = createLogger({
+ fileLabel,
+});
+
+export type BrowserPageBootstrapProps = MultiversalAppBootstrapProps;
+
+/**
+ * Bootstraps the page, only when rendered on the browser
+ *
+ * @param props
+ */
+const BrowserPageBootstrap = (props: BrowserPageBootstrapProps): JSX.Element => {
+ const {
+ Component,
+ err,
+ router,
+ } = props;
+ // When the page is served by the browser, some browser-only properties are available
+ const pageProps = props.pageProps as unknown as MultiversalPageProps;
+ const {
+ customerRef,
+ lang,
+ locale,
+ } = pageProps;
+ const {
+ t,
+ i18n,
+ } = useTranslation();
+ const dataset = useDataset();
+ const customer: Customer = useCustomer();
+ const isInIframe: boolean = isRunningInIframe();
+ const iframeReferrer: string = getIframeReferrer();
+ const cookiesManager: UniversalCookiesManager = new UniversalCookiesManager(); // On browser, we can access cookies directly (doesn't need req/res or page context)
+ const userSession: UserSemiPersistentSession = cookiesManager.getUserData();
+ const userId = userSession.id;
+ const injectedPageProps: MultiversalPageProps = {
+ ...props.pageProps,
+ isInIframe,
+ iframeReferrer,
+ cookiesManager,
+ userSession,
+ };
+ const theme = useTheme();
+ const isCypressRunning = detectCypress();
+ const isLightHouseRunning = detectLightHouse();
+ const networkSpeed: ClientNetworkInformationSpeed = getClientNetworkInformationSpeed();
+ const networkConnectionType: ClientNetworkConnectionType = getClientNetworkConnectionType();
+
+ // Configure Sentry user and track navigation through breadcrumb
+ configureSentryUserMetadata(userSession);
+ configureSentryBrowserMetadata(networkSpeed, networkConnectionType, isInIframe, iframeReferrer);
+
+ Sentry.addBreadcrumb({ // See https://docs.sentry.io/enriching-error-data/breadcrumbs
+ category: fileLabel,
+ message: `Rendering ${fileLabel}`,
+ level: Sentry.Severity.Debug,
+ });
+
+ const userConsent: UserConsent = getUserConsent();
+ const {
+ isUserOptedOutOfAnalytics,
+ hasUserGivenAnyCookieConsent,
+ } = userConsent;
+ const amplitudeInstance: AmplitudeClient = getAmplitudeInstance({
+ customerRef,
+ iframeReferrer,
+ isInIframe,
+ lang,
+ locale,
+ userId,
+ userConsent,
+ networkSpeed,
+ networkConnectionType,
+ });
+
+ // Init the Cookie Consent popup, which will open on the browser
+ initCookieConsent({
+ allowedPages: [ // We only allow it on those pages to avoid display that boring popup on every page
+ `${window.location.origin}/${locale}/demo/terms`,
+ `${window.location.origin}/${locale}/demo/privacy`,
+ `${window.location.origin}/${locale}/demo/built-in-features/cookies-consent`,
+ ],
+ amplitudeInstance,
+ locale,
+ t,
+ theme,
+ userConsent,
+ });
+
+ // XXX Inject data so that Cypress can use them to run dynamic tests.
+ // Those data mustn't be sensitive. They'll be available in the DOM, no matter the stage of the app.
+ // This is needed to run E2E tests that are specific to a customer. (dynamic testing)
+ window[CYPRESS_WINDOW_NS] = {
+ dataset,
+ customer,
+ };
+
+ // In non-production stages, bind some utilities to the browser's DOM, for ease of quick testing
+ if (process.env.NEXT_PUBLIC_APP_STAGE !== 'production') {
+ window['amplitudeInstance'] = amplitudeInstance;
+ window['i18n'] = i18n;
+ window['router'] = router;
+ window['t'] = t;
+ logger.info(`Utilities have been bound to the DOM for quick testing (only in non-production stages):
+ - amplitudeInstance
+ - i18n
+ - router
+ - t
+ `);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default BrowserPageBootstrap;
diff --git a/src/app/components/MultiversalAppBootstrap.tsx b/src/app/components/MultiversalAppBootstrap.tsx
new file mode 100644
index 000000000..9e0e2c536
--- /dev/null
+++ b/src/app/components/MultiversalAppBootstrap.tsx
@@ -0,0 +1,412 @@
+import Loader from '@/components/animations/Loader';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import { SSRPageProps } from '@/layouts/core/types/SSRPageProps';
+import { useApollo } from '@/modules/core/apollo/apolloClient';
+import customerContext from '@/modules/core/data/contexts/customerContext';
+import datasetContext from '@/modules/core/data/contexts/datasetContext';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { CustomerTheme } from '@/modules/core/data/types/CustomerTheme';
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+import DefaultErrorLayout from '@/modules/core/errorHandling/DefaultErrorLayout';
+import i18nContext from '@/modules/core/i18n/contexts/i18nContext';
+import i18nextLocize from '@/modules/core/i18n/i18nextLocize';
+import { stringifyQueryParameters } from '@/modules/core/i18n/i18nRouter';
+import { detectLightHouse } from '@/modules/core/lightHouse/lighthouse';
+import { createLogger } from '@/modules/core/logging/logger';
+import previewModeContext from '@/modules/core/previewMode/contexts/previewModeContext';
+import {
+ startPreviewMode,
+ stopPreviewMode,
+} from '@/modules/core/previewMode/previewMode';
+import quickPreviewContext from '@/modules/core/quickPreview/contexts/quickPreviewContext';
+import { configureSentryI18n } from '@/modules/core/sentry/universal';
+import deserializeSafe from '@/modules/core/serializeSafe/deserializeSafe';
+import { detectCypress } from '@/modules/core/testing/cypress';
+import { initCustomerTheme } from '@/modules/core/theming/theme';
+import { NotFound404PageName } from '@/pages/404';
+import ErrorPage from '@/pages/_error';
+import { NO_AUTO_PREVIEW_MODE_KEY } from '@/pages/api/preview';
+import { ApolloProvider } from '@apollo/client';
+import { ThemeProvider } from '@emotion/react';
+import * as Sentry from '@sentry/node';
+import { isBrowser } from '@unly/utils';
+import { i18n } from 'i18next';
+import isEmpty from 'lodash.isempty';
+import size from 'lodash.size';
+import React, { useState } from 'react';
+import getComponentName from '../getComponentName';
+import { MultiversalAppBootstrapProps } from '../types/MultiversalAppBootstrapProps';
+import BrowserPageBootstrap, { BrowserPageBootstrapProps } from './BrowserPageBootstrap';
+import MultiversalGlobalStyles from './MultiversalGlobalStyles';
+import ServerPageBootstrap, { ServerPageBootstrapProps } from './ServerPageBootstrap';
+
+const fileLabel = 'app/components/MultiversalAppBootstrap';
+const logger = createLogger({
+ fileLabel,
+});
+
+type Props = MultiversalAppBootstrapProps | MultiversalAppBootstrapProps;
+
+/**
+ * Bootstraps a page and renders it
+ *
+ * Basically does everything a Page component needs to be rendered.
+ * All behaviors defined here are applied across the whole application (they're common to all pages)
+ *
+ * @param props
+ */
+const MultiversalAppBootstrap: React.FunctionComponent = (props): JSX.Element => {
+ const {
+ pageProps,
+ router,
+ } = props;
+ // When using SSG with "fallback: true" and the page hasn't been generated yet then isSSGFallbackInitialBuild is true
+ const [isSSGFallbackInitialBuild] = useState(isEmpty(pageProps) && router?.isFallback === true);
+ const pageComponentName = getComponentName(props.Component);
+ const apolloClient = useApollo(pageProps);
+
+ Sentry.addBreadcrumb({ // See https://docs.sentry.io/enriching-error-data/breadcrumbs
+ category: fileLabel,
+ message: `Rendering ${fileLabel}`,
+ level: Sentry.Severity.Debug,
+ });
+
+ // Configure meaningful Next.js props in Sentry for easier debugging (all errors will report the props being passed to the page)
+ // Filter out all entities that are too large, which might cause Sentry to fail sending the request
+ Sentry.configureScope((scope): void => {
+ const {
+ Component,
+ pageProps,
+ err,
+ router,
+ ...restProps
+ } = props;
+ const {
+ asPath,
+ basePath,
+ defaultLocale,
+ isFallback,
+ isSsr,
+ locale,
+ locales,
+ pathname,
+ query,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ ...restRouter // Other router props aren't interesting to track and are being ignored
+ } = router;
+ const {
+ serializedDataset, // Size might be too big
+ i18nTranslations, // Size might be too big
+ ...restPageProps
+ } = pageProps; // XXX Exclude all non-meaningful props that might be too large for Sentry to handle, to avoid "403 Entity too large"
+ const serializedDatasetLength = (serializedDataset || '').length;
+
+ // Track meaningful _app.props + unknown props. Other props (router, pageProps) will be tracked in another Sentry context for readability (DX)
+ scope.setContext('_app.props (filtered)', {
+ pageComponentName: pageComponentName,
+ err: props.err,
+ ...restProps,
+ });
+ scope.setTag('hasCaughtNextErr', !!props?.err);
+ scope.setContext('_app.router (filtered)', {
+ asPath,
+ basePath,
+ defaultLocale,
+ isFallback,
+ isSsr,
+ locale,
+ locales,
+ pathname,
+ query,
+ });
+ scope.setContext('_app.pageProps (filtered)', {
+ serializedDatasetLength,
+ ...restPageProps,
+ });
+ scope.setContext('build', {
+ buildTime: process.env.NEXT_PUBLIC_BUILD_TIME,
+ buildTimeISO: (new Date(process.env.NEXT_PUBLIC_APP_BUILD_TIME || null)).toISOString(),
+ buildId: process.env.NEXT_PUBLIC_APP_BUILD_ID,
+ });
+ });
+
+ if (isBrowser() && process.env.NEXT_PUBLIC_APP_STAGE !== 'production') { // Avoids log clutter on server
+ console.debug('MultiversalAppBootstrap.props', props); // eslint-disable-line no-console
+ }
+
+ // Display a loader (we could use a skeleton too) when this happens, so that the user doesn't face a white page until the page is generated and displayed
+ if (isSSGFallbackInitialBuild && router?.isFallback) { // When router.isFallback becomes "false", then it'll mean the page has been generated and rendered and we can display it, instead of the loader
+ return (
+
+ );
+ }
+
+ if (pageProps.isReadyToRender || pageProps.statusCode === 404) {
+ if (!process.env.IS_SERVER_INITIAL_BUILD) { // Avoids noise when building the whole app
+ logger.info('MultiversalAppBootstrap - App is ready, rendering...');
+ }
+
+ const {
+ serializedDataset,
+ i18nTranslations,
+ lang,
+ locale,
+ }: SSGPageProps | SSRPageProps = pageProps;
+ configureSentryI18n(lang, locale);
+
+ // Unrecoverable error, we can't even display the layout because we don't have the minimal required information to properly do so.
+ // The reason can be a UI crash (something broke due to the user's interaction) and a top-level error was thrown in props.err.
+ // Or, it can be because no serializedDataset was provided.
+ // Either way, we display the error page, which will take care of reporting the error to Sentry and display an error message depending on the environment.
+ if (typeof serializedDataset !== 'string') {
+ // eslint-disable-next-line no-console
+ console.log('props', props);
+
+ if (props.err) {
+ const error = new Error(`Fatal error - A top-level error was thrown by the application, which caused the Page.props to be lost. \n
+ The page cannot be shown to the end-user, an error page will be displayed.`);
+ logger.error(error);
+
+ return (
+
+
+
+ );
+ } else {
+ const error = new Error(`Fatal error - Unexpected "serializedDataset" passed as page props.\n
+ Expecting string, but got "${typeof serializedDataset}".\n
+ This error is often caused by returning an invalid "serializedDataset" from a getStaticProps/getServerSideProps.\n
+ Make sure you return a correct value, using "serializeSafe".`);
+
+ return (
+
+
+
+ );
+ }
+ }
+
+ if (process.env.NEXT_PUBLIC_APP_STAGE !== 'production' && !process.env.IS_SERVER_INITIAL_BUILD) {
+ // XXX It's too cumbersome to do proper typings when type changes
+ // The "customer" was forwarded as a JSON-ish string (using Flatten) in order to avoid circular dependencies issues (SSG/SSR)
+ // It now being converted back into an object to be actually usable on all pages
+ // eslint-disable-next-line no-console
+ logger.debug('pageProps.serializedDataset length (bytes)', (serializedDataset as unknown as string)?.length);
+ // logger.debug('serializedDataset', serializedDataset);
+ }
+
+ const dataset: GraphCMSDataset = deserializeSafe(serializedDataset);
+ const customer: Customer = dataset?.customer;
+
+ if (process.env.NEXT_PUBLIC_APP_STAGE !== 'production' && isBrowser()) {
+ // eslint-disable-next-line no-console
+ console.debug(`pageProps.dataset (${size(Object.keys(dataset))} items)`, dataset);
+ // eslint-disable-next-line no-console
+ console.debug('dataset.customer', customer);
+ }
+
+ let isPreviewModeEnabled;
+ let previewData;
+ let isQuickPreviewPage;
+
+ if ('preview' in pageProps) {
+ // SSG
+ isPreviewModeEnabled = pageProps?.preview;
+ previewData = pageProps?.previewData;
+
+ if (isBrowser()) {
+ const queryParameters: string = stringifyQueryParameters(router);
+ const isCypressRunning = detectCypress();
+ const isLightHouseRunning = detectLightHouse();
+ const noAutoPreviewMode = new URLSearchParams(window?.location?.search)?.get(NO_AUTO_PREVIEW_MODE_KEY) === 'true';
+
+ // XXX If we are running in staging stage and the preview mode is not enabled, then we force enable it
+ // We do this to enforce the staging stage is being used as a "preview environment" so it satisfies our publication workflow
+ // If we're running in development, then we don't enforce anything
+ // If we're running in production, then we force disable the preview mode, because we don't want to allow it in production
+ // XXX Also, don't enable preview mode when Cypress or LightHouse are running to avoid bad performances
+ if (process.env.NEXT_PUBLIC_APP_STAGE === 'staging' && !isPreviewModeEnabled && !isCypressRunning && !isLightHouseRunning && !noAutoPreviewMode) {
+ startPreviewMode(queryParameters);
+ } else if (process.env.NEXT_PUBLIC_APP_STAGE === 'production' && isPreviewModeEnabled) {
+ logger.error('Preview mode is not allowed in production, but was detected as enabled. It will now be disabled by force.');
+ Sentry.captureMessage('Preview mode is not allowed in production, but was detected as enabled. It will now be disabled by force.', Sentry.Severity.Error);
+ stopPreviewMode(queryParameters);
+ }
+ }
+ } else {
+ // SSR
+ isPreviewModeEnabled = false;
+ previewData = null;
+ isQuickPreviewPage = pageProps?.isQuickPreviewPage;
+ }
+
+ // Don't treat 404 pages like errors, it's expected in 404 pages not to have all the props the app needs
+ if (props?.Component?.name !== NotFound404PageName) {
+ // If the app is misconfigured, simulate a native Next.js error to catch the misconfiguration early
+ if (!customer || !i18nTranslations || !lang || !locale) {
+ let error = props.err || null;
+
+ // Unrecoverable error, we can't even display the layout because we don't have the minimal required information to properly do so
+ // This most likely means something went wrong, and we must display the error page in such case
+ if (!error) {
+ // The most-likely issue could be that we failed to fetch the customer related to "process.env.NEXT_PUBLIC_CUSTOMER_REF"
+ // E.g: This will happens when an instance was deployed for a customer, but the customer.ref was changed since then.
+ if (process.env.NEXT_PUBLIC_CUSTOMER_REF !== customer?.ref) {
+ error = new Error(process.env.NEXT_PUBLIC_APP_STAGE === 'production' ?
+ `Fatal error - An error happened, the page cannot be displayed. (customer doesn't match)` :
+ `Fatal error when bootstrapping the app ("${props?.Component?.name}"). The "customer.ref" doesn't match (expected: "${process.env.NEXT_PUBLIC_CUSTOMER_REF}", received: "${customer?.ref}").`,
+ );
+ } else {
+ error = new Error(process.env.NEXT_PUBLIC_APP_STAGE === 'production' ?
+ `Fatal error - An error happened, the page cannot be displayed.` :
+ `Fatal error when bootstrapping the app ("${props?.Component?.name}"). It might happen when lang/locale/translations couldn't be resolved.`,
+ );
+ }
+ } else {
+ // If an error was detected by Next, then it means the current state is due to a top-level that was caught before
+ // We don't have anything to do, as it's automatically logged into Sentry
+ const error = new Error(`Fatal error - Misconfiguration detected, the page cannot be displayed.`);
+ logger.error(error);
+ }
+
+ return (
+
+
+
+ );
+ } else if (props?.err) {
+ // If an error was caught by Next.js (but wasn't fatal since we reached this point), we log it to Sentry to make sure we'll be notified
+ Sentry.withScope((scope): void => {
+ Sentry.captureException(props.err);
+ });
+ }
+ } else {
+ if (!process.env.IS_SERVER_INITIAL_BUILD) { // Avoids capturing false-positive 404 pages when building the 404 page
+ // XXX Opinionated: Record an exception in Sentry for 404, if you don't want this then uncomment the below code
+ const err = new Error(`Page not found (404) for "${router?.asPath}"`);
+
+ logger.warn(err);
+ Sentry.captureException(err);
+ }
+ }
+
+ const i18nextInstance: i18n = i18nextLocize(lang, i18nTranslations); // Apply i18next configuration with Locize backend
+ const customerTheme: CustomerTheme = initCustomerTheme(customer);
+
+ /*
+ * We split the rendering between server and browser
+ * There are actually 3 rendering modes, each of them has its own set of limitations
+ * 1. SSR (doesn't have access to browser-related features (LocalStorage), but it does have access to request-related data (cookies, HTTP headers))
+ * 2. Server during SSG (doesn't have access to browser-related features (LocalStorage), nor to request-related data (cookies, localStorage, HTTP headers))
+ * 3. Static rendering (doesn't have access to server-related features (HTTP headers), but does have access to request-related data (cookie) and browser-related features (LocalStorage))
+ *
+ * What we do here, is to avoid rendering browser-related stuff if we're not running in a browser, because it cannot work properly.
+ * (e.g: Generating cookies will work, but they won't be stored on the end-user device, and it would create "Text content did not match" warnings, if generated from the server during SSG)
+ *
+ * So, the BrowserPageBootstrap does browser-related stuff and then call the PageBootstrap which takes care of stuff that is universal (identical between browser and server)
+ *
+ * XXX If you're concerned regarding React rehydration, read our talk with Josh, author of https://joshwcomeau.com/react/the-perils-of-rehydration/
+ * https://twitter.com/Vadorequest/status/1257658553361408002
+ *
+ * XXX There may be more rendering modes - See https://github.com/vercel/next.js/discussions/12558#discussioncomment-12303
+ */
+ let browserPageBootstrapProps: BrowserPageBootstrapProps;
+ let serverPageBootstrapProps: ServerPageBootstrapProps;
+
+ if (isBrowser()) {
+ browserPageBootstrapProps = {
+ ...props,
+ router,
+ pageProps: {
+ ...pageProps,
+ i18nextInstance,
+ isSSGFallbackInitialBuild: isSSGFallbackInitialBuild,
+ customerTheme,
+ },
+ };
+ } else {
+ serverPageBootstrapProps = {
+ ...props,
+ router,
+ pageProps: {
+ ...pageProps,
+ i18nextInstance,
+ isSSGFallbackInitialBuild: isSSGFallbackInitialBuild,
+ customerTheme,
+ },
+ };
+ }
+
+ return (
+
+
+
+
+
+
+ {/* XXX Global styles that applies to all pages go there */}
+
+
+
+ {
+ isBrowser() ? (
+
+ ) : (
+
+ )
+ }
+
+
+
+
+
+
+
+ );
+
+ } else {
+ // We wait for out props to contain "isReadyToRender: true", which means they've been set correctly by either getInitialProps/getStaticProps/getServerProps
+ // This helps avoid multiple useless renders (especially in development mode) and thus avoid noisy logs
+ // XXX I've recently tested without it and didn't notice any more logs than expected/usual. Maybe this was from a time where there were multiple full-renders? It may be removed if so (TODO later with proper testing)
+ // eslint-disable-next-line no-console
+ console.info('MultiversalAppBootstrap - App is not ready yet, waiting for isReadyToRender');
+ return null;
+ }
+};
+
+export default MultiversalAppBootstrap;
diff --git a/src/app/components/MultiversalGlobalExternalStyles.tsx b/src/app/components/MultiversalGlobalExternalStyles.tsx
new file mode 100644
index 000000000..5cb506ba1
--- /dev/null
+++ b/src/app/components/MultiversalGlobalExternalStyles.tsx
@@ -0,0 +1,12 @@
+/**
+ * Contains all imports of external CSS libs (.css files) that must be injected in all Next.js pages.
+ *
+ * This approach is preferred over importing them all one by one within the _app.tsx file, because it's easier to maintain.
+ *
+ * Also, this file is being imported by both "src/pages/_app.tsx" and ".storybook/preview.js",
+ * so that global 3rd party CSS are included when previewing components, too.
+ */
+import 'animate.css/animate.min.css'; // Loads animate.css CSS file. See https://github.com/daneden/animate.css
+import 'bootstrap/dist/css/bootstrap.min.css'; // Loads bootstrap CSS file. See https://stackoverflow.com/a/50002905/2391795
+import 'cookieconsent/build/cookieconsent.min.css'; // Loads CookieConsent CSS file. See https://github.com/osano/cookieconsent
+import 'rc-tooltip/assets/bootstrap.css';
diff --git a/src/app/components/MultiversalGlobalStyles.tsx b/src/app/components/MultiversalGlobalStyles.tsx
new file mode 100644
index 000000000..62cb1c5f3
--- /dev/null
+++ b/src/app/components/MultiversalGlobalStyles.tsx
@@ -0,0 +1,288 @@
+import { CustomerTheme } from '@/modules/core/data/types/CustomerTheme';
+import {
+ AllowedVariableFont,
+ injectFontFamily,
+} from '@/modules/core/fonts/fonts';
+import {
+ css,
+ Global,
+} from '@emotion/react';
+import React from 'react';
+import {
+ NRN_DEFAULT_FALLBACK_FONTS,
+ NRN_DEFAULT_FONT,
+} from '../constants';
+
+type Props = {
+ customerTheme: CustomerTheme;
+}
+
+/**
+ * Those styles are applied
+ * - universally (browser + server)
+ * - globally (applied to all pages), through Layouts
+ *
+ * @param props
+ */
+const MultiversalGlobalStyles: React.FunctionComponent = (props): JSX.Element => {
+ const { customerTheme } = props;
+ const {
+ primaryColor,
+ primaryColorVariant1,
+ onPrimaryColor,
+ secondaryColor,
+ secondaryColorVariant1,
+ onSecondaryColor,
+ backgroundColor,
+ onBackgroundColor,
+ surfaceColor,
+ onSurfaceColor,
+ errorColor,
+ onErrorColor,
+ fonts: activeFont,
+ ...rest
+ } = customerTheme;
+ const fontName: AllowedVariableFont = activeFont || NRN_DEFAULT_FONT;
+ const fontFamily = injectFontFamily(fontName);
+
+ return (
+
+ );
+};
+
+export default MultiversalGlobalStyles;
diff --git a/src/app/components/ServerPageBootstrap.tsx b/src/app/components/ServerPageBootstrap.tsx
new file mode 100644
index 000000000..a5a1d4d79
--- /dev/null
+++ b/src/app/components/ServerPageBootstrap.tsx
@@ -0,0 +1,56 @@
+import { MultiversalPageProps } from '@/layouts/core/types/MultiversalPageProps';
+import { OnlyServerPageProps } from '@/layouts/core/types/OnlyServerPageProps';
+import { createLogger } from '@/modules/core/logging/logger';
+import { configureSentryUserMetadata } from '@/modules/core/sentry/universal';
+import { userSessionContext } from '@/modules/core/userSession/userSessionContext';
+import * as Sentry from '@sentry/node';
+import React from 'react';
+import { MultiversalAppBootstrapPageProps } from '../types/MultiversalAppBootstrapPageProps';
+import { MultiversalAppBootstrapProps } from '../types/MultiversalAppBootstrapProps';
+
+const fileLabel = 'app/components/ServerPageBootstrap';
+const logger = createLogger({
+ fileLabel,
+});
+
+export type ServerPageBootstrapProps = MultiversalAppBootstrapProps;
+
+/**
+ * Bootstraps the page, only when rendered on the server
+ *
+ * @param props
+ */
+const ServerPageBootstrap = (props: ServerPageBootstrapProps): JSX.Element => {
+ const {
+ Component,
+ err,
+ } = props;
+ // When the page is served by the server, some server-only properties are available
+ const pageProps = props.pageProps as unknown as MultiversalPageProps;
+ const injectedPageProps: MultiversalPageProps = {
+ ...pageProps,
+ };
+ const {
+ userSession,
+ } = pageProps;
+
+ // Configure Sentry user and track navigation through breadcrumb
+ configureSentryUserMetadata(userSession);
+ Sentry.addBreadcrumb({ // See https://docs.sentry.io/enriching-error-data/breadcrumbs
+ category: fileLabel,
+ message: `Rendering ${fileLabel}`,
+ level: Sentry.Severity.Debug,
+ });
+
+ return (
+
+
+
+ );
+};
+
+export default ServerPageBootstrap;
diff --git a/src/app/constants.ts b/src/app/constants.ts
new file mode 100644
index 000000000..07a87aebf
--- /dev/null
+++ b/src/app/constants.ts
@@ -0,0 +1,147 @@
+import { CustomerTheme } from '@/modules/core/data/types/CustomerTheme';
+import { AllowedVariableFont } from '@/modules/core/fonts/fonts';
+import { resolveVariantColor } from '@/modules/core/theming/colors';
+
+export const NRN_DEFAULT_SERVICE_LABEL = process.env.NEXT_PUBLIC_APP_STAGE === 'production' ? 'Next Right Now' : `[${process.env.NEXT_PUBLIC_APP_STAGE === 'staging' ? 'Preview' : 'Dev'}] Next Right Now`;
+
+/**
+ * Co-branding logo displayed in the footer ("powered by Unly")
+ */
+export const NRN_CO_BRANDING_LOGO_URL = '/static/images/LOGO_Powered_by_UNLY_BLACK_BLUE.svg';
+
+/**
+ * Fallback fonts used until our own fonts have been loaded by the browser.
+ * Should only use native fonts that are installed on all devices by default.
+ *
+ * @see https://leerob.io/blog/fonts#system-fonts Inspired by
+ * @see https://systemfontstack.com/ Basic system font stacks
+ * @see https://www.w3schools.com/cssref/css_websafe_fonts.asp
+ * @see https://developer.mozilla.org/en-US/docs/Learn/CSS/Styling_text/Fundamentals#web_safe_fonts
+ */
+export const NRN_DEFAULT_FALLBACK_FONTS = `-apple-system, BlinkMacSystemFont, sans-serif`;
+
+/**
+ * Font used once our font have been loaded by the browser.
+ */
+export const NRN_DEFAULT_FONT: AllowedVariableFont = `Manrope`;
+
+/**
+ * Theme applied by default when no theme is defined.
+ * Will be used on a variable-by-variable basis based on what's configured on the CMS for the customer.
+ *
+ * Applied through "src/modules/core/theming/theme.ts" default value transformations.
+ * Strongly inspired from Material Design Color System.
+ *
+ * @see The below documentation comes from https://material.io/design/color/the-color-system.html
+ */
+export const NRN_DEFAULT_THEME: Omit & {
+ primaryColorVariant1: (primaryColor: string) => string;
+ secondaryColorVariant1: (primaryColor: string) => string;
+} = {
+ /**
+ * A primary color is the color displayed most frequently across your app's screens and components.
+ */
+ primaryColor: '#0028FF',
+
+ /**
+ * Primary color first variant, meant to highlight interactive elements using the primary color (hover, etc.).
+ */
+ primaryColorVariant1: resolveVariantColor,
+
+ /**
+ * Color applied to text, icons, surfaces displayed on top of the primary color.
+ */
+ onPrimaryColor: '#fff',
+
+ /**
+ * A secondary color provides more ways to accent and distinguish your product.
+ * Having a secondary color is optional, and should be applied sparingly to accent select parts of your UI.
+ * If you don’t have a secondary color, your primary color can also be used to accent elements.
+ *
+ * The secondary color should be the same as the primary color, when no particular secondary color is being used.
+ *
+ * Secondary colors are best for:
+ * - Floating action buttons
+ * - Selection controls, like sliders and switches
+ * - Highlighting selected text
+ * - Progress bars
+ * - Links and headlines
+ */
+ secondaryColor: '#000',
+
+ /**
+ * Secondary color first variant, meant to highlight interactive elements using the primary color (hover, etc.).
+ */
+ secondaryColorVariant1: resolveVariantColor,
+
+ /**
+ * Color applied to text, icons, surfaces displayed on top of the secondary color.
+ */
+ onSecondaryColor: '#fff',
+
+ /**
+ * Background colors don’t represent brand.
+ *
+ * The background color appears behind top-level content.
+ *
+ * Used by/for:
+ * - All pages background
+ */
+ backgroundColor: '#f4f4f4',
+
+ /**
+ * Color applied to text, icons, surfaces displayed on top of the background color.
+ */
+ onBackgroundColor: '#000',
+
+ /**
+ * Surface colors don’t represent brand.
+ *
+ * Surface colors affect surfaces of components, such as cards, sheets, and menus.
+ */
+ surfaceColor: '#fff',
+
+ /**
+ * Color applied to text, icons, surfaces displayed on top of the surface color.
+ */
+ onSurfaceColor: '#000',
+
+ /**
+ * Error colors don’t represent brand.
+ *
+ * Error color indicates errors in components, such as invalid text in a text field.
+ * The baseline error color is #B00020.
+ */
+ errorColor: '#FFE0E0',
+
+ /**
+ * Color applied to text, icons, surfaces displayed on top of the error color.
+ */
+ onErrorColor: '#FE6262',
+
+ /**
+ * Logo displayed on the top footer.
+ */
+ logo: null,
+
+ /**
+ * Fonts used by the application.
+ *
+ * XXX At the moment, we don't allow the customer to define its own font, even though it's part of the customer theme.
+ */
+ fonts: NRN_DEFAULT_FONT,
+};
+
+export const GITHUB_API_BASE_URL = 'https://api.github.com/';
+
+/**
+ * Your GitHub Owner name.
+ * Used by "startVercelDeployment" API endpoint.
+ */
+export const GITHUB_OWNER_NAME = 'UnlyEd';
+
+/**
+ * Your GitHub Repository name.
+ * Used by "startVercelDeployment" API endpoint.
+ */
+export const GITHUB_REPO_NAME = 'next-right-now';
diff --git a/src/app/getComponentName.ts b/src/app/getComponentName.ts
new file mode 100644
index 000000000..cde9d37b7
--- /dev/null
+++ b/src/app/getComponentName.ts
@@ -0,0 +1,24 @@
+import {
+ NextComponentType,
+ NextPageContext,
+} from 'next';
+
+/**
+ * Resolves the name of a Next.js "pageProps.Component".
+ * A "Component" is a "Page".
+ *
+ * Extract the name from the component function source code.
+ *
+ * @param Component
+ */
+export const getComponentName = (Component: NextComponentType): string | null => {
+ try {
+ const componentAsString = Component.toString();
+
+ return componentAsString.split('(')[0].split(' ')[1];
+ } catch (e) {
+ return null;
+ }
+};
+
+export default getComponentName;
diff --git a/src/app/isNextApiRequest.ts b/src/app/isNextApiRequest.ts
new file mode 100644
index 000000000..f523bd9f3
--- /dev/null
+++ b/src/app/isNextApiRequest.ts
@@ -0,0 +1,14 @@
+import { IncomingMessage } from 'http';
+import { NextApiRequest } from 'next';
+
+/**
+ * TS type guard resolving whether "req" matches a "NextApiRequest" object.
+ *
+ * @param req
+ *
+ * @see https://www.typescripttutorial.net/typescript-tutorial/typescript-type-guards/
+ * @see https://www.logicbig.com/tutorials/misc/typescript/type-guards.html
+ */
+export const isNextApiRequest = (req: NextApiRequest | IncomingMessage): req is NextApiRequest => {
+ return (req as NextApiRequest).body !== undefined;
+};
diff --git a/src/app/types/CommonServerSideParams.ts b/src/app/types/CommonServerSideParams.ts
new file mode 100644
index 000000000..31f58f4be
--- /dev/null
+++ b/src/app/types/CommonServerSideParams.ts
@@ -0,0 +1,15 @@
+import { ParsedUrlQuery } from 'querystring';
+
+/**
+ * Server side params provided to any page (SSG or SSR)
+ * - Static params provided to getStaticProps and getStaticPaths for static pages (when building SSG pages)
+ * - Dynamic params provided to getServerSideProps (when using SSR)
+ *
+ * Those params come from the route (url) being used, they are affected by "redirects" and the route name (e.g: "/folder/[id].tsx"
+ *
+ * @see next.config.js "redirects" section for url params
+ */
+export type CommonServerSideParams = {
+ albumId?: string; // Used by album-[albumId]-with-ssg-and-fallback page
+ locale?: string; // The first path of the url is the "locale"
+} & E;
diff --git a/src/app/types/MultiversalAppBootstrapPageProps.ts b/src/app/types/MultiversalAppBootstrapPageProps.ts
new file mode 100644
index 000000000..16a431136
--- /dev/null
+++ b/src/app/types/MultiversalAppBootstrapPageProps.ts
@@ -0,0 +1,11 @@
+import { CustomerTheme } from '@/modules/core/data/types/CustomerTheme';
+import { i18n } from 'i18next';
+
+/**
+ * Additional props that are injected by MultiversalAppBootstrap to all pages
+ */
+export type MultiversalAppBootstrapPageProps = {
+ i18nextInstance: i18n;
+ isSSGFallbackInitialBuild: boolean; // When true, means the app is loading a SSG page, with fallback mode enabled, and this page hasn't been built before
+ customerTheme: CustomerTheme;
+}
diff --git a/src/app/types/MultiversalAppBootstrapProps.ts b/src/app/types/MultiversalAppBootstrapProps.ts
new file mode 100644
index 000000000..ab7a57a9a
--- /dev/null
+++ b/src/app/types/MultiversalAppBootstrapProps.ts
@@ -0,0 +1,24 @@
+import { MultiversalPageProps } from '@/layouts/core/types/MultiversalPageProps';
+import {
+ NextComponentType,
+ NextPageContext,
+} from 'next';
+import { Router } from 'next/router';
+
+/**
+ * Props that are provided to the render function of the application (in _app)
+ * Those props can be consolidated by either getInitialProps, getServerProps or getStaticProps, depending on the page and its configuration
+ *
+ * @see MultiversalAppBootstrap for usage
+ */
+export type MultiversalAppBootstrapProps = {
+ Component?: NextComponentType; // Page component, not provided if pageProps.statusCode is 3xx or 4xx
+ err?: Error; // Only defined if there was an error
+ pageProps?: PP; // Props forwarded to the Page component
+ router?: Router;
+
+ // XXX Next.js internals (unstable API) - See https://github.com/vercel/next.js/discussions/12558#discussioncomment-9177
+ __N_SSG?: boolean; // Stands for "server-side generated" or "static site generation", indicates the page was generated through getStaticProps
+ __N_SSR?: boolean; // Stands for "server-side rendering", indicates the page was generated through getServerSideProps
+ __N_SSP?: boolean; // Stands for "server-side props"
+};
diff --git a/src/app/types/StaticPath.ts b/src/app/types/StaticPath.ts
new file mode 100644
index 000000000..929176aa1
--- /dev/null
+++ b/src/app/types/StaticPath.ts
@@ -0,0 +1,5 @@
+import { CommonServerSideParams } from './CommonServerSideParams';
+
+export type StaticPath = {
+ params: CommonServerSideParams;
+}
diff --git a/src/app/types/StaticPathsOutput.ts b/src/app/types/StaticPathsOutput.ts
new file mode 100644
index 000000000..7c4f476df
--- /dev/null
+++ b/src/app/types/StaticPathsOutput.ts
@@ -0,0 +1,11 @@
+import { CommonServerSideParams } from './CommonServerSideParams';
+
+/**
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation
+ */
+export type StaticPathsOutput = {
+ fallback: boolean | 'blocking'; // See https://nextjs.org/docs/basic-features/data-fetching#the-fallback-key-required
+ paths: (string | {
+ params: CommonServerSideParams;
+ })[];
+}
diff --git a/src/app/types/StaticPropsInput.ts b/src/app/types/StaticPropsInput.ts
new file mode 100644
index 000000000..6efacba4c
--- /dev/null
+++ b/src/app/types/StaticPropsInput.ts
@@ -0,0 +1,14 @@
+import { PreviewData } from '@/modules/core/previewMode/types/PreviewData';
+import { CommonServerSideParams } from './CommonServerSideParams';
+
+/**
+ * Static props given as inputs for getStaticProps
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation
+ * @see node_modules/next/types/index.d.ts
+ */
+export type StaticPropsInput = {
+ params?: CommonServerSideParams;
+ preview: boolean;
+ previewData: PreviewData;
+}
diff --git a/src/common/README.md b/src/common/README.md
new file mode 100644
index 000000000..409a7e559
--- /dev/null
+++ b/src/common/README.md
@@ -0,0 +1,10 @@
+Common
+===
+
+> Check out the [documentation about the folder structure](../README.md#common-folder)
+
+Summary:
+
+- This folder uses an MVC-ish design pattern, where you split your files in separated folders, depending on their kind.
+- This folder is great to quickly write some piece of code, you don't need to think a lot about how organized your code should be, and can get started quickly.
+- If you don't know or are unsure whether to go for `common` or `modules`, pick `common`. You can always change your mind later.
diff --git a/src/common/components/ComponentTemplate.tsx b/src/common/components/ComponentTemplate.tsx
new file mode 100644
index 000000000..2c63798ae
--- /dev/null
+++ b/src/common/components/ComponentTemplate.tsx
@@ -0,0 +1,23 @@
+import { css } from '@emotion/react';
+import React from 'react';
+
+type Props = {}
+
+/**
+ * This component is a template meant to be duplicated to quickly get started with new React components.
+ */
+const ComponentTemplate: React.FunctionComponent = (props): JSX.Element => {
+ return (
+
+ This component is a template meant to be duplicated to quickly get started with new React components.
+
+ Feel free to adapt it at your convenience
+
-
- {
- logEvent('open-admin-site');
- }}
- title={'Edit dynamic content using GraphCMS and react-admin!'}
- >
-
- {t('nav.adminSite.link', 'Admin site')}
-
-
-
-
-
-
- )}
-
- );
-};
-
-type Props = {
- customer: Customer;
- theme: Theme;
- t: Function;
- router: NextRouter;
-}
-
-export default compose(
- withTranslation(['common']),
-)(Nav);
diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx
deleted file mode 100644
index 65cf78fc6..000000000
--- a/src/components/Tooltip.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-/** @jsx jsx */
-import { jsx } from '@emotion/core';
-import RCTooltip from 'rc-tooltip';
-import React from 'react';
-
-/**
- * Tooltip with sane defaults that improve usability and accessibility.
- *
- * Uses React Component Tooltip (https://github.com/react-component/tooltip)
- * XXX Feel free to add more API options, I've only added what seemed necessary but they support plenty more!
- *
- * @param {Props} props
- * @return {JSX.Element}
- */
-const Tooltip: React.FunctionComponent = (props: Props): JSX.Element => {
- const {
- children,
- overlay,
- trigger = ['hover', 'click', 'focus'],
- placement = 'top',
- ...rest
- } = props;
-
- return (
-
- {children}
-
- );
-};
-
-type Props = {
- children: React.ReactElement;
- overlay: React.ReactElement;
- trigger?: Array;
- placement?: string;
- visible?: boolean;
-}
-
-export default Tooltip;
diff --git a/src/components/svg/EnglishFlag.tsx b/src/components/svg/EnglishFlag.tsx
deleted file mode 100644
index 20d95c460..000000000
--- a/src/components/svg/EnglishFlag.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { css } from '@emotion/core';
-import React from 'react';
-
-const SvgEnglishFlag = props => {
- return (
-
- );
-};
-
-type Props = {} & React.SVGProps;
-export default SvgEnglishFlag;
diff --git a/src/components/svg/FrenchFlag.tsx b/src/components/svg/FrenchFlag.tsx
deleted file mode 100644
index 0e1dd276a..000000000
--- a/src/components/svg/FrenchFlag.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from 'react';
-
-const SvgFrenchFlag = props => {
- return (
-
- );
-};
-
-export default SvgFrenchFlag;
diff --git a/src/constants.ts b/src/constants.ts
deleted file mode 100644
index 72432b0c7..000000000
--- a/src/constants.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Theme } from './types/data/Theme';
-
-export const NRN_DEFAULT_SERVICE_LABEL = 'Next Right Now!';
-export const NRN_DEFAULT_FONT = 'neuzeit-grotesk';
-export const NRN_DEFAULT_SECONDARY_COLOR = '#fff';
-
-export const NRN_DEFAULT_THEME: Theme = {
- primaryColor: 'blue',
-};
diff --git a/src/gql/common/layoutQuery.ts b/src/gql/common/layoutQuery.ts
deleted file mode 100644
index 9ec27fbd8..000000000
--- a/src/gql/common/layoutQuery.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import gql from 'graphql-tag';
-
-import { theme } from '../fragments/theme';
-
-/**
- * Used in all pages
- */
-export const LAYOUT_QUERY = gql`
- query LAYOUT_QUERY($customerRef: String!){
- customer(where: {
- ref: $customerRef,
- }){
- id
- label
- theme {
- ...themeFields
- }
- }
- }
- ${theme.themeFields}
-`;
diff --git a/src/gql/pages/examples.ts b/src/gql/pages/examples.ts
deleted file mode 100644
index 8dad2dfe9..000000000
--- a/src/gql/pages/examples.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import gql from 'graphql-tag';
-
-import { asset } from '../fragments/asset';
-import { product } from '../fragments/product';
-import { theme } from '../fragments/theme';
-
-/**
- * Used for /examples page
- */
-export const EXAMPLES_PAGE_QUERY = gql`
- query INDEX_PAGE_QUERY($customerRef: String!){
- products(where: {
- status: PUBLISHED
- customer: {
- ref: $customerRef
- }
- }){
- ...productFields
- }
- }
- ${theme.themeFields}
- ${asset.assetFields}
- ${product.productFields}
-`;
diff --git a/src/hoc/withUniversalGraphQLDataLoader.ts b/src/hoc/withUniversalGraphQLDataLoader.ts
deleted file mode 100644
index 13d9dc3ec..000000000
--- a/src/hoc/withUniversalGraphQLDataLoader.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { getDataFromTree } from '@apollo/react-ssr';
-import { InMemoryCache, NormalizedCacheObject } from 'apollo-cache-inmemory';
-import { ApolloClient } from 'apollo-client';
-import { createHttpLink } from 'apollo-link-http';
-import fetch from 'isomorphic-unfetch';
-import withApollo, { InitApolloOptions } from 'next-with-apollo';
-
-// XXX This config is used on the FRONTEND (from the browser) or on the BACKEND (server), depending on whether it's loaded from SSR or client-side
-const link = createHttpLink({
- fetch, // Switches between unfetch & node-fetch for client & server.
- uri: process.env.GRAPHQL_API_ENDPOINT,
-
- // Headers applied here will be applied for all requests
- // See the use of the "options" when running a graphQL query to specify options per-request at https://www.apollographql.com/docs/react/api/react-hooks/#options
- headers: {
- 'gcms-locale-no-default': false,
- 'authorization': `Bearer ${process.env.GRAPHQL_API_KEY}`,
- },
- credentials: 'same-origin', // XXX See https://www.apollographql.com/docs/react/recipes/authentication#cookie
-});
-
-/**
- * Export a HOC from next-with-apollo
- *
- * Universal, works both on client and server sides
- * Doesn't fetch any data by itself, but provides a client that allows to do it in children components
- *
- * @see https://www.npmjs.com/package/next-with-apollo
- */
-export default withApollo(
- ({ initialState }: InitApolloOptions) =>
- new ApolloClient({
- link: link,
-
- // XXX Very important to provide the initialState, otherwise the client will replay the query upon loading,
- // which is useless as the data were already fetched by the server (SSR)
- cache: new InMemoryCache().restore(initialState || {}), // rehydrate the cache using the initial data passed from the server
- }), {
- getDataFromTree,
- },
-);
diff --git a/src/layouts/README.md b/src/layouts/README.md
new file mode 100644
index 000000000..270eb43c6
--- /dev/null
+++ b/src/layouts/README.md
@@ -0,0 +1,15 @@
+Page layouts
+===
+
+> Check out the [documentation about the folder structure](../README.md#folder-structure)
+
+Summary:
+
+- `core`: Share reusable components between layouts.
+- `demo`: Used by all demo pages. You'll eventually get rid of it, but until then it can be a good inspiration.
+- `public`: Only used by a single page (at "/public"), meant to be the layout for public pages.
+- `quickPreview`: Used by some demo pages. Keep it if you need it!
+- You can add custom layouts and use them in your pages right away.
+- Layouts are flexible, we used the `DemoLayout` in all pages under `pages/[locale]/demo` but **you don't have to**, it was a choice.
+- Layouts are usually useful when you want to have a similar UI shared by several pages.
+- Layouts are meant to avoid code duplication between pages sharing the same layout and increase code maintainability.
diff --git a/src/layouts/core/components/CoreHead.tsx b/src/layouts/core/components/CoreHead.tsx
new file mode 100644
index 000000000..af16ad2e5
--- /dev/null
+++ b/src/layouts/core/components/CoreHead.tsx
@@ -0,0 +1,165 @@
+import {
+ NRN_DEFAULT_FONT,
+ NRN_DEFAULT_SERVICE_LABEL,
+} from '@/app/constants';
+import useCustomer from '@/modules/core/data/hooks/useCustomer';
+import { Customer } from '@/modules/core/data/types/Customer';
+import {
+ AllowedVariableFont,
+ fontsBasePath,
+ VariableFontConfig,
+ variableFontsConfig,
+} from '@/modules/core/fonts/fonts';
+import { SUPPORTED_LOCALES } from '@/modules/core/i18n/i18n';
+import { I18nLocale } from '@/modules/core/i18n/types/I18nLocale';
+import NextHead from 'next/head';
+import React from 'react';
+
+export type HeadProps = {
+ /**
+ * Title of the page. (SEO)
+ *
+ * Displayed in the browser tab.
+ */
+ seoTitle?: string;
+
+ /**
+ * Description of the page. (SEO)
+ *
+ * Used as Open Graph and twitter description.
+ */
+ seoDescription?: string;
+
+ /**
+ * Url of the page. (SEO)
+ *
+ * Used as Open Graph url.
+ */
+ seoUrl?: string;
+
+ /**
+ * Image associated with the page. (SEO)
+ *
+ * Used as Open Graph and twitter image.
+ */
+ seoImage?: string;
+
+ /**
+ * Favicon.
+ *
+ * Websites usually use the same favicon for all their pages.
+ */
+ favicon?: string;
+
+ /**
+ * Additional links and scripts HTML elements.
+ *
+ * Can be used to load 3rd party scripts and such.
+ * It is recommended to use a "" as wrapper.
+ */
+ additionalContent?: React.ReactElement;
+}
+
+/**
+ * Custom Next.js Head component.
+ *
+ * Configures SEO, load fonts.
+ *
+ * TODO SEO should be done differently. See https://github.com/UnlyEd/next-right-now/issues/150
+ *
+ * XXX Core component, meant to be used by other layouts, shouldn't be used by other components directly.
+ *
+ * https://github.com/vercel/next.js#populating-head
+ */
+const CoreHead: React.FunctionComponent = (props): JSX.Element => {
+ const customer: Customer = useCustomer();
+
+ const defaultDescription = 'Flexible production-grade boilerplate with Next.js 11, Vercel and TypeScript. Includes multiple opt-in presets using Storybook, GraphQL, Analytics, CSS-in-JS, Monitoring, End-to-end testing, Internationalization, CI/CD and SaaS B2B multiple single-tenants (monorepo) support';
+ const defaultMetaURL = 'https://github.com/UnlyEd/next-right-now';
+ const defaultMetaImage = customer?.theme?.logo?.url;
+ const defaultFavicon = '/favicon.ico';
+
+ const {
+ seoTitle = NRN_DEFAULT_SERVICE_LABEL,
+ seoDescription = defaultDescription,
+ seoImage = defaultMetaImage,
+ seoUrl = defaultMetaURL,
+ favicon = defaultFavicon,
+ additionalContent = null,
+ } = props;
+ const fontName: AllowedVariableFont = customer?.theme?.fonts || NRN_DEFAULT_FONT;
+ const config: VariableFontConfig = variableFontsConfig.find((config) => config?.fontName === fontName);
+ const format = config?.format || 'woff2';
+ const fontFile = config?.fontFile || `${fontName}-variable-latin.${format}`;
+
+ return (
+
+
+ {seoTitle}
+
+
+
+
+ {/* Preload the font */}
+
+
+ {
+ SUPPORTED_LOCALES.map((supportedLocale: I18nLocale) => {
+ // Google best practice for SEO https://webmasters.googleblog.com/2011/12/new-markup-for-multilingual-content.html
+ // Google accepts relative links for hreflang
+ // See https://stackoverflow.com/questions/28291574/are-relative-links-valid-in-link-rel-alternate-hreflang-tags
+ // See https://webmasters.googleblog.com/2013/04/5-common-mistakes-with-relcanonical.html
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {/* Detect outdated browser and display a popup about how to upgrade to a more recent browser/version */}
+ {/* XXX See public/static/libs/detect-outdated-browser/README.md */}
+ {/*
+ XXX DISABLED because of https://github.com/mikemaccana/outdated-browser-rework/issues/57#issuecomment-620532590
+ TLDR; Display false-positive warnings on embedded browsers (on mobile devices) if they're too old and the user can't do anything about it (e.g: Facebook Chrome, Linkedin Chrome, etc.)
+ */}
+ {/**/}
+ {/**/}
+
+ {
+ additionalContent && (
+ additionalContent
+ )
+ }
+
+
+ );
+};
+
+export default CoreHead;
diff --git a/src/layouts/core/components/CoreLayout.tsx b/src/layouts/core/components/CoreLayout.tsx
new file mode 100644
index 000000000..27b62375b
--- /dev/null
+++ b/src/layouts/core/components/CoreLayout.tsx
@@ -0,0 +1,229 @@
+import { SoftPageProps } from '@/layouts/core/types/SoftPageProps';
+import { GenericObject } from '@/modules/core/data/types/GenericObject';
+import DefaultErrorLayout from '@/modules/core/errorHandling/DefaultErrorLayout';
+import { createLogger } from '@/modules/core/logging/logger';
+import PreviewModeBanner from '@/modules/core/previewMode/components/PreviewModeBanner';
+import ErrorPage from '@/pages/_error';
+import {
+ Amplitude,
+ LogOnMount,
+} from '@amplitude/react-amplitude';
+import {
+ css,
+ SerializedStyles,
+} from '@emotion/react';
+import * as Sentry from '@sentry/node';
+import classnames from 'classnames';
+import {
+ NextRouter,
+ useRouter,
+} from 'next/router';
+import React from 'react';
+import CoreHead, { HeadProps } from './CoreHead';
+import CorePageContainer from './CorePageContainer';
+
+const fileLabel = 'layouts/core/components/CoreLayout';
+const logger = createLogger({
+ fileLabel,
+});
+
+export type Props = {
+ /**
+ * Content to display within the layout.
+ *
+ * Essentially, the page's content.
+ */
+ children: React.ReactNode;
+
+ /**
+ * Name of the layout.
+ *
+ * Will be used as CSS class for the main wrapper element.
+ */
+ layoutName: 'public-layout' | 'demo-layout';
+
+ /**
+ * CSS applied to the main wrapper element.
+ *
+ * @example layoutBaseCSS={css`
+ * margin: 20px;
+ * `}
+ */
+ layoutBaseCSS?: SerializedStyles;
+
+ /**
+ * Props forwarded to the Head component.
+ *
+ * Essentially, SEO metadata, etc.
+ * Will use sane defaults if not specified.
+ */
+ headProps?: HeadProps;
+
+ /**
+ * Internal name of the page.
+ *
+ * Used by Amplitude, for analytics.
+ * All events happening on the page will be linked to that page name.
+ */
+ pageName: string;
+
+ /**
+ * Wrapper container for the page.
+ *
+ * By default, uses CorePageContainer component.
+ */
+ PageContainer?: React.FunctionComponent;
+
+ /**
+ * Force hiding the nav.
+ */
+ hideNav?: boolean;
+
+ /**
+ * Force hiding the footer.
+ */
+ hideFooter?: boolean;
+
+ /**
+ * Force hiding the preview banner.
+ */
+ hidePreviewBanner?: boolean;
+
+ /**
+ * Component to use as Head.
+ *
+ * @default BaseHead
+ */
+ Head?: React.FunctionComponent;
+
+ /**
+ * Component to use as Footer.
+ *
+ * @default BaseFooter
+ */
+ Footer?: React.FunctionComponent;
+
+ /**
+ * Component to use as Nav.
+ *
+ * @default BaseNav
+ */
+ Nav?: React.FunctionComponent;
+} & SoftPageProps;
+
+/**
+ * Handles the positioning of top-level elements within the page.
+ *
+ * It does the following:
+ * - Adds a Nav/Footer component, and the dynamic Next.js "Page" component in between.
+ * - Automatically track page views (Amplitude).
+ * - Handles errors by displaying the Error page, with the ability to contact technical support (which will send a Sentry User Feedback).
+ *
+ * XXX Core component, meant to be used by other layouts, shouldn't be used by other components directly.
+ */
+const CoreLayout: React.FunctionComponent = (props): JSX.Element => {
+ const {
+ children,
+ error,
+ isInIframe = false, // Won't be defined server-side
+ layoutName,
+ layoutBaseCSS,
+ headProps = {},
+ pageName,
+ PageContainer = CorePageContainer,
+ hideNav,
+ hideFooter = true,
+ hidePreviewBanner = true,
+ Head = CoreHead,
+ Footer = null,
+ Nav = null,
+ } = props;
+ const router: NextRouter = useRouter();
+ const isIframeWithFullPagePreview = router?.query?.fullPagePreview === '1';
+ const isPreviewModeBannerDisplayed = !hidePreviewBanner && process.env.NEXT_PUBLIC_APP_STAGE !== 'production';
+ const isNavDisplayed = !hideNav && (!isInIframe || isIframeWithFullPagePreview) && Nav;
+ const isFooterDisplayed = !hideFooter && (!isInIframe || isIframeWithFullPagePreview) && Footer;
+
+ Sentry.addBreadcrumb({ // See https://docs.sentry.io/enriching-error-data/breadcrumbs
+ category: fileLabel,
+ message: `Rendering ${fileLabel} for page ${pageName}`,
+ level: Sentry.Severity.Debug,
+ });
+
+ Sentry.configureScope((scope): void => {
+ scope.setTag('fileLabel', fileLabel);
+ });
+
+ return (
+ ({
+ ...inheritedProps,
+ page: {
+ ...inheritedProps.page,
+ name: pageName,
+ },
+ })}
+ >
+
+
+
+
+ {/* Loaded from components/Head - See https://github.com/mikemaccana/outdated-browser-rework */}
+ {/**/}
+
+ {
+ isPreviewModeBannerDisplayed && (
+
+ )
+ }
+
+ {
+ (isNavDisplayed) && (
+
+ )
+ }
+
+
+ {
+ // If an error happened, we display it instead of displaying the page
+ // We display a custom error instead of the native Next.js error by providing children (removing children will display the native Next.js error)
+ error ? (
+
+
+
+ ) : (
+
+ {children}
+
+ )
+ }
+
+
+ {
+ (isFooterDisplayed) && (
+
+ )
+ }
+
+
+ );
+};
+
+export default CoreLayout;
diff --git a/src/layouts/core/components/CorePageContainer.tsx b/src/layouts/core/components/CorePageContainer.tsx
new file mode 100644
index 000000000..22badef89
--- /dev/null
+++ b/src/layouts/core/components/CorePageContainer.tsx
@@ -0,0 +1,34 @@
+import { css } from '@emotion/react';
+import React from 'react';
+import { Container } from 'reactstrap';
+
+type Props = {
+ children: React.ReactNode;
+}
+
+/**
+ * Page wrapper handling the display of the Next.js Page component (as "children").
+ *
+ * XXX Core component, meant to be used by other layouts, shouldn't be used by other components directly.
+ */
+const CorePageContainer: React.FunctionComponent = (props): JSX.Element => {
+ const {
+ children,
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+
+};
+
+export default CorePageContainer;
diff --git a/src/layouts/core/coreLayoutSSG.ts b/src/layouts/core/coreLayoutSSG.ts
new file mode 100644
index 000000000..a5db8bdaf
--- /dev/null
+++ b/src/layouts/core/coreLayoutSSG.ts
@@ -0,0 +1,194 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { StaticPath } from '@/app/types/StaticPath';
+import { StaticPathsOutput } from '@/app/types/StaticPathsOutput';
+import { StaticPropsInput } from '@/app/types/StaticPropsInput';
+import { DEMO_STATIC_DATA_QUERY } from '@/common/gql/demoStaticDataQuery';
+import {
+ GetCoreLayoutStaticPaths,
+ GetCoreLayoutStaticPathsOptions,
+} from '@/layouts/core/types/GetCoreLayoutStaticPaths';
+import {
+ GetCoreLayoutStaticProps,
+ GetCoreLayoutStaticPropsOptions,
+} from '@/layouts/core/types/GetCoreLayoutStaticProps';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import {
+ APOLLO_STATE_PROP_NAME,
+ getApolloState,
+ initializeApollo,
+} from '@/modules/core/apollo/apolloClient';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+import { getGraphcmsDataset } from '@/modules/core/gql/getGraphcmsDataset';
+import { prepareGraphCMSLocaleHeader } from '@/modules/core/gql/graphcms';
+import {
+ StaticCustomer,
+ StaticDataset,
+} from '@/modules/core/gql/types/StaticDataset';
+import { getLocizeTranslations } from '@/modules/core/i18n/getLocizeTranslations';
+import {
+ DEFAULT_LOCALE,
+ resolveFallbackLanguage,
+} from '@/modules/core/i18n/i18n';
+import { I18nextResources } from '@/modules/core/i18n/i18nextLocize';
+import { createLogger } from '@/modules/core/logging/logger';
+import { PreviewData } from '@/modules/core/previewMode/types/PreviewData';
+import serializeSafe from '@/modules/core/serializeSafe/serializeSafe';
+import {
+ ApolloClient,
+ ApolloQueryResult,
+ NormalizedCacheObject,
+} from '@apollo/client';
+import includes from 'lodash.includes';
+import map from 'lodash.map';
+import {
+ GetStaticPaths,
+ GetStaticPathsContext,
+ GetStaticProps,
+ GetStaticPropsResult,
+} from 'next';
+
+const fileLabel = 'layouts/core/coreLayoutSSG';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Returns a "getStaticPaths" function.
+ *
+ * @param options
+ */
+export const getCoreLayoutStaticPaths: GetCoreLayoutStaticPaths = (options?: GetCoreLayoutStaticPathsOptions) => {
+ const {
+ fallback = false,
+ } = options || {};
+
+ /**
+ * Only executed on the server side at build time.
+ * Computes all static paths that should be available for all SSG pages.
+ * Necessary when a page has dynamic routes and uses "getStaticProps", in order to build the HTML pages.
+ *
+ * You can use "fallback" option to avoid building all page variants and allow runtime fallback.
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ * Can be overridden for per-page customisation (e.g: deepmerge).
+ *
+ * XXX Core component, meant to be used by other layouts, shouldn't be used by other components directly.
+ *
+ * @return Static paths that will be used by "getCoreLayoutStaticProps" to generate pages
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation
+ */
+ const getStaticPaths: GetStaticPaths = async (context: GetStaticPathsContext): Promise => {
+ const lang = DEFAULT_LOCALE;
+ const bestCountryCodes: string[] = [lang, resolveFallbackLanguage(lang)];
+ const gcmsLocales: string = prepareGraphCMSLocaleHeader(bestCountryCodes);
+ const dataset: StaticDataset | GraphCMSDataset = await getGraphcmsDataset(gcmsLocales);
+ const customer: StaticCustomer | Customer = dataset?.customer;
+
+ // Generate only pages for languages that have been allowed by the customer
+ const paths: StaticPath[] = map(customer?.availableLanguages, (availableLanguage: string): StaticPath => {
+ return {
+ params: {
+ locale: availableLanguage,
+ },
+ };
+ });
+
+ return {
+ fallback,
+ paths,
+ };
+ };
+
+ return getStaticPaths;
+};
+
+/**
+ * Returns a "getStaticProps" function.
+ *
+ * Disables redirecting to the 404 page when building the 404 page.
+ *
+ * @param options
+ */
+export const getCoreLayoutStaticProps: GetCoreLayoutStaticProps = (options?: GetCoreLayoutStaticPropsOptions): GetStaticProps => {
+ const {
+ enable404Redirect = true,
+ } = options || {};
+
+ /**
+ * Only executed on the server side at build time.
+ * Computes all static props that should be available for all SSG pages.
+ *
+ * Note that when a page uses "getStaticProps", then "_app:getInitialProps" is executed (if defined) but not actually used by the page,
+ * only the results from getStaticProps are actually injected into the page (as "SSGPageProps").
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ * Can be overridden for per-page customisation (e.g: deepmerge).
+ *
+ * XXX Core component, meant to be used by other layouts, shouldn't be used by other components directly.
+ *
+ * @return Props (as "SSGPageProps") that will be passed to the Page component, as props (known as "pageProps" in _app).
+ *
+ * @see https://github.com/vercel/next.js/discussions/10949#discussioncomment-6884
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation
+ */
+ const getStaticProps: GetStaticProps = async (props: StaticPropsInput): Promise> => {
+ const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const preview: boolean = props?.preview || false;
+ const previewData: PreviewData = props?.previewData || null;
+ const hasLocaleFromUrl = !!props?.params?.locale;
+ const locale: string = hasLocaleFromUrl ? props?.params?.locale : DEFAULT_LOCALE; // If the locale isn't found (e.g: 404 page)
+ const lang: string = locale.split('-')?.[0];
+ const bestCountryCodes: string[] = [lang, resolveFallbackLanguage(lang)];
+ const gcmsLocales: string = prepareGraphCMSLocaleHeader(bestCountryCodes);
+ const i18nTranslations: I18nextResources = await getLocizeTranslations(lang);
+ const dataset: StaticDataset | GraphCMSDataset = await getGraphcmsDataset(gcmsLocales);
+ const apolloClient: ApolloClient = initializeApollo();
+ const variables = {
+ customerRef,
+ };
+
+ // XXX Updating apollo client cache with static data
+ apolloClient.writeQuery({
+ query: DEMO_STATIC_DATA_QUERY,
+ data: dataset,
+ variables
+ });
+
+ const {
+ customer,
+ } = dataset || {}; // XXX Use empty object as fallback, to avoid app crash when destructuring, if no data is returned
+
+ // Do not serve pages using locales the customer doesn't have enabled (useful during preview mode and in development env)
+ if (enable404Redirect && !includes(customer?.availableLanguages, locale)) {
+ logger.warn(`Locale "${locale}" not enabled for this customer (allowed: "${customer?.availableLanguages}"), returning 404 page.`);
+
+ return {
+ notFound: true,
+ };
+ }
+
+ return {
+ // Props returned here will be available as page properties (pageProps)
+ props: {
+ [APOLLO_STATE_PROP_NAME]: getApolloState(apolloClient),
+ bestCountryCodes,
+ serializedDataset: serializeSafe(dataset),
+ customer,
+ customerRef,
+ i18nTranslations,
+ gcmsLocales,
+ hasLocaleFromUrl,
+ isReadyToRender: true,
+ isStaticRendering: true,
+ lang,
+ locale,
+ preview,
+ previewData,
+ },
+ };
+ };
+
+ return getStaticProps;
+};
diff --git a/src/layouts/core/coreLayoutSSR.ts b/src/layouts/core/coreLayoutSSR.ts
new file mode 100644
index 000000000..17e51e631
--- /dev/null
+++ b/src/layouts/core/coreLayoutSSR.ts
@@ -0,0 +1,162 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { DEMO_LAYOUT_QUERY } from '@/common/gql/demoLayoutQuery';
+import {
+ GetCoreLayoutServerSideProps,
+ GetCoreServerSidePropsOptions,
+} from '@/layouts/core/types/GetCoreLayoutServerSideProps';
+import { PublicHeaders } from '@/layouts/core/types/PublicHeaders';
+import { SSRPageProps } from '@/layouts/core/types/SSRPageProps';
+import { initializeApollo } from '@/modules/core/apollo/apolloClient';
+import { Cookies } from '@/modules/core/cookiesManager/types/Cookies';
+import UniversalCookiesManager from '@/modules/core/cookiesManager/UniversalCookiesManager';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+import { getGraphcmsDataset } from '@/modules/core/gql/getGraphcmsDataset';
+import { prepareGraphCMSLocaleHeader } from '@/modules/core/gql/graphcms';
+import { ApolloQueryOptions } from '@/modules/core/gql/types/ApolloQueryOptions';
+import {
+ StaticCustomer,
+ StaticDataset,
+} from '@/modules/core/gql/types/StaticDataset';
+import { getLocizeTranslations } from '@/modules/core/i18n/getLocizeTranslations';
+import {
+ resolveFallbackLanguage,
+ resolveSSRLocale,
+} from '@/modules/core/i18n/i18n';
+import { I18nextResources } from '@/modules/core/i18n/i18nextLocize';
+import { createLogger } from '@/modules/core/logging/logger';
+import { isQuickPreviewRequest } from '@/modules/core/quickPreview/quickPreview';
+import { UserSemiPersistentSession } from '@/modules/core/userSession/types/UserSemiPersistentSession';
+import {
+ ApolloClient,
+ NormalizedCacheObject,
+} from '@apollo/client';
+import { IncomingMessage } from 'http';
+import includes from 'lodash.includes';
+import {
+ GetServerSideProps,
+ GetServerSidePropsContext,
+ GetServerSidePropsResult,
+} from 'next';
+import NextCookies from 'next-cookies';
+
+const fileLabel = 'layouts/core/coreLayoutSSR';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * "getCoreLayoutServerSideProps" returns only part of the props expected in SSRPageProps.
+ * To avoid TS errors, we omit those that we don't return, and add those necessary to the "getServerSideProps" function.
+ */
+export type GetCoreLayoutServerSidePropsResults = Omit & {
+ apolloClient: ApolloClient;
+ layoutQueryOptions: ApolloQueryOptions;
+ headers: PublicHeaders;
+}
+
+/**
+ * Returns a "getServerSideProps" function.
+ *
+ * Disables redirecting to the 404 page when building the 404 page.
+ *
+ * @param options
+ */
+export const getCoreLayoutServerSideProps: GetCoreLayoutServerSideProps = (options?: GetCoreServerSidePropsOptions) => {
+ const {
+ enable404Redirect = true,
+ } = options || {};
+
+ /**
+ * Only executed on the server side, for every request.
+ * Computes some dynamic props that should be available for all SSR pages that use getServerSideProps.
+ *
+ * Because the exact GQL query will depend on the consumer (AKA "caller"), this helper doesn't run any query by itself, but rather return all necessary props to allow the consumer to perform its own queries.
+ * This improves performances, by only running one GQL query instead of many (consumer's choice).
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ *
+ * XXX Core component, meant to be used by other layouts, shouldn't be used by other components directly.
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering
+ */
+ const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext): Promise> => {
+ const {
+ query,
+ params,
+ req,
+ res,
+ ...rest
+ } = context;
+ const isQuickPreviewPage: boolean = isQuickPreviewRequest(req);
+ const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const readonlyCookies: Cookies = NextCookies(context); // Parses Next.js cookies in a universal way (server + client)
+ const cookiesManager: UniversalCookiesManager = new UniversalCookiesManager(req, res); // Cannot be forwarded as pageProps, because contains circular refs
+ const userSession: UserSemiPersistentSession = cookiesManager.getUserData();
+ const { headers }: IncomingMessage = req;
+ const publicHeaders: PublicHeaders = {
+ 'accept-language': headers?.['accept-language'],
+ 'user-agent': headers?.['user-agent'],
+ 'host': headers?.host,
+ };
+ const hasLocaleFromUrl = !!query?.locale;
+ const locale: string = resolveSSRLocale(query, req, readonlyCookies);
+ const lang: string = locale.split('-')?.[0];
+ const bestCountryCodes: string[] = [lang, resolveFallbackLanguage(lang)];
+ const gcmsLocales: string = prepareGraphCMSLocaleHeader(bestCountryCodes);
+ const i18nTranslations: I18nextResources = await getLocizeTranslations(lang);
+ // XXX This part is not using "getGraphcmsDataset" because I'm not sure how to return the "apolloClient" instance when doing so, as it'll be wrapped and isn't returned
+ // So, code is duplicated, but that works fine
+ const apolloClient: ApolloClient = initializeApollo();
+ const variables = {
+ customerRef,
+ };
+ const layoutQueryOptions: ApolloQueryOptions = {
+ displayName: 'DEMO_LAYOUT_QUERY',
+ query: DEMO_LAYOUT_QUERY,
+ variables,
+ context: {
+ headers: {
+ 'gcms-locales': gcmsLocales,
+ },
+ },
+ };
+
+ const dataset: StaticDataset | GraphCMSDataset = await getGraphcmsDataset(gcmsLocales);
+ const customer: StaticCustomer | Customer = dataset?.customer;
+
+ // Do not serve pages using locales the customer doesn't have enabled
+ if (enable404Redirect && !includes(customer?.availableLanguages, locale)) {
+ logger.warn(`Locale "${locale}" not enabled for this customer (allowed: "${customer?.availableLanguages}"), returning 404 page.`);
+
+ return {
+ notFound: true,
+ };
+ }
+
+ // Most props returned here will be necessary for the app to work properly (see "SSRPageProps")
+ // Some props are meant to be helpful to the consumer and won't be passed down to the _app.render (e.g: apolloClient, layoutQueryOptions)
+ return {
+ props: {
+ apolloClient,
+ bestCountryCodes,
+ serializedDataset: null, // We don't send the dataset yet (we don't have any because we haven't fetched the database yet), but it must be done by SSR pages in"getServerSideProps"
+ customerRef,
+ i18nTranslations,
+ headers: publicHeaders,
+ gcmsLocales,
+ hasLocaleFromUrl,
+ isReadyToRender: true,
+ isServerRendering: true,
+ lang,
+ locale,
+ layoutQueryOptions,
+ readonlyCookies,
+ userSession,
+ isQuickPreviewPage,
+ },
+ };
+ };
+
+ return getServerSideProps;
+};
diff --git a/src/layouts/core/types/GetCoreLayoutServerSideProps.ts b/src/layouts/core/types/GetCoreLayoutServerSideProps.ts
new file mode 100644
index 000000000..21f14ba61
--- /dev/null
+++ b/src/layouts/core/types/GetCoreLayoutServerSideProps.ts
@@ -0,0 +1,23 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { GetCoreLayoutServerSidePropsResults } from '@/layouts/core/coreLayoutSSR';
+import { GetServerSideProps } from 'next';
+
+/**
+ * The getCoreLayoutServerSideProps is a function returning a getServerSideProps function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getServerSideProps" function by providing options.
+ */
+export type GetCoreLayoutServerSideProps = (options?: GetCoreServerSidePropsOptions) => GetServerSideProps;
+
+/**
+ * Options allowed in GetCoreLayoutServerSideProps function.
+ */
+export type GetCoreServerSidePropsOptions = {
+ /**
+ * Whether allowing any redirection to a 404 page.
+ *
+ * @default true
+ */
+ enable404Redirect: boolean;
+};
diff --git a/src/layouts/core/types/GetCoreLayoutStaticPaths.ts b/src/layouts/core/types/GetCoreLayoutStaticPaths.ts
new file mode 100644
index 000000000..121ed233b
--- /dev/null
+++ b/src/layouts/core/types/GetCoreLayoutStaticPaths.ts
@@ -0,0 +1,25 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { GetStaticPaths } from 'next';
+
+/**
+ * The getCoreLayoutStaticPaths is a function returning a getStaticPaths function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getStaticProps" function by providing options.
+ */
+export type GetCoreLayoutStaticPaths = (options?: GetCoreLayoutStaticPathsOptions) => GetStaticPaths;
+
+/**
+ * Options allowed in GetCoreLayoutStaticPaths function.
+ */
+export type GetCoreLayoutStaticPathsOptions = {
+ /**
+ * Enables fallback mode.
+ *
+ * @default false
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#fallback-true
+ * @see https://nextjs.org/docs/basic-features/data-fetching#the-fallback-key-required
+ */
+ fallback: boolean;
+};
diff --git a/src/layouts/core/types/GetCoreLayoutStaticProps.ts b/src/layouts/core/types/GetCoreLayoutStaticProps.ts
new file mode 100644
index 000000000..53f7355b2
--- /dev/null
+++ b/src/layouts/core/types/GetCoreLayoutStaticProps.ts
@@ -0,0 +1,26 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import { GetStaticProps } from 'next';
+
+/**
+ * The getCoreLayoutStaticProps is a function returning a getStaticProps function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getStaticProps" function by providing options.
+ *
+ * This is necessary for the 404 page, which must never return a { notFound: true } object.
+ * It allows to conditionally return { notFound: true }, and avoid doing so for that particular page.
+ */
+export type GetCoreLayoutStaticProps = (options?: GetCoreLayoutStaticPropsOptions) => GetStaticProps;
+
+/**
+ * Options allowed in GetCoreLayoutStaticProps function.
+ */
+export type GetCoreLayoutStaticPropsOptions = {
+ /**
+ * Whether allowing any redirection to a 404 page.
+ *
+ * @default true
+ */
+ enable404Redirect: boolean;
+};
diff --git a/src/layouts/core/types/MultiversalPageProps.ts b/src/layouts/core/types/MultiversalPageProps.ts
new file mode 100644
index 000000000..9a7f93be8
--- /dev/null
+++ b/src/layouts/core/types/MultiversalPageProps.ts
@@ -0,0 +1,25 @@
+import { ApolloState } from '@/modules/core/apollo/apolloClient';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { I18nextResources } from '@/modules/core/i18n/i18nextLocize';
+
+/**
+ * Page properties available on all pages, whether they're rendered statically, dynamically, from the server or the client
+ *
+ * Multiversal page props are listed in MultiversalPageProps
+ * Server-side page props are listed in SSRPageProps
+ * Client-side page props are listed in SSGPageProps
+ */
+export type MultiversalPageProps = {
+ bestCountryCodes: string[];
+ serializedDataset: string; // Transferred from server to browser as JSON (using Flatten.stringify), then parsed on the browser/server within the MultiversalAppBootstrap
+ customer: Customer;
+ customerRef: string;
+ error?: Error; // Only defined if there was an error
+ gcmsLocales: string;
+ hasLocaleFromUrl: boolean;
+ i18nTranslations: I18nextResources;
+ isReadyToRender: boolean;
+ lang: string;
+ locale: string;
+ statusCode?: number; // Provided by Next.js framework, sometimes
+} & ApolloState & E;
diff --git a/src/layouts/core/types/OnlyBrowserPageProps.ts b/src/layouts/core/types/OnlyBrowserPageProps.ts
new file mode 100644
index 000000000..ae9175beb
--- /dev/null
+++ b/src/layouts/core/types/OnlyBrowserPageProps.ts
@@ -0,0 +1,12 @@
+import UniversalCookiesManager from '@/modules/core/cookiesManager/UniversalCookiesManager';
+import { UserSemiPersistentSession } from '@/modules/core/userSession/types/UserSemiPersistentSession';
+
+/**
+ * Props only available on the browser side, for all pages
+ */
+export type OnlyBrowserPageProps = {
+ cookiesManager: UniversalCookiesManager;
+ iframeReferrer: string;
+ isInIframe: boolean;
+ userSession: UserSemiPersistentSession; // User session (from browser cookies)
+};
diff --git a/src/layouts/core/types/OnlyServerPageProps.ts b/src/layouts/core/types/OnlyServerPageProps.ts
new file mode 100644
index 000000000..897aa5e9a
--- /dev/null
+++ b/src/layouts/core/types/OnlyServerPageProps.ts
@@ -0,0 +1,12 @@
+import { Cookies } from '@/modules/core/cookiesManager/types/Cookies';
+import { UserSemiPersistentSession } from '@/modules/core/userSession/types/UserSemiPersistentSession';
+import { PublicHeaders } from './PublicHeaders';
+
+/**
+ * Props only available on the server side, for all pages
+ */
+export type OnlyServerPageProps = {
+ headers: PublicHeaders; // Headers made public to the client-side
+ readonlyCookies: Cookies; // Cookies retrieved using https://www.npmjs.com/package/next-cookies - Aren't really readonly but don't provide any setter
+ userSession: UserSemiPersistentSession; // User session (from server cookies)
+};
diff --git a/src/types/PublicHeaders.ts b/src/layouts/core/types/PublicHeaders.ts
similarity index 81%
rename from src/types/PublicHeaders.ts
rename to src/layouts/core/types/PublicHeaders.ts
index 39779ab7e..87ffa7e81 100644
--- a/src/types/PublicHeaders.ts
+++ b/src/layouts/core/types/PublicHeaders.ts
@@ -1,7 +1,7 @@
/**
* Headers that are shared to the client by the server (and therefore made public)
*/
-export declare type PublicHeaders = {
+export type PublicHeaders = {
'accept-language'?: string;
'user-agent'?: string;
host?: string;
diff --git a/src/layouts/core/types/SSGPageProps.ts b/src/layouts/core/types/SSGPageProps.ts
new file mode 100644
index 000000000..0b558fadd
--- /dev/null
+++ b/src/layouts/core/types/SSGPageProps.ts
@@ -0,0 +1,22 @@
+import { MultiversalAppBootstrapPageProps } from '@/app/types/MultiversalAppBootstrapPageProps';
+import { PreviewData } from '@/modules/core/previewMode/types/PreviewData';
+import { MultiversalPageProps } from './MultiversalPageProps';
+
+/**
+ * Static properties returned by getStaticProps for static pages (using SSG)
+ * Mind that those properties are generated from the server, when building the static bundle
+ *
+ * Multiversal page props are listed in MultiversalPageProps
+ * Server-side page props are listed in SSRPageProps
+ * Client-side page props are listed in SSGPageProps
+ *
+ * XXX SSGPageProps doesn't extend from OnlyBrowserPageProps (like SSRPageProps does with OnlyServerPageProps) because SSG properties are actually generated by the server and don't have access to browser variables
+ */
+export type SSGPageProps = {
+ // Props that are specific to SSG
+ isStaticRendering: boolean;
+ preview: boolean;
+ previewData: PreviewData;
+} & MultiversalPageProps // Generic props that are provided immediately, no matter what
+ & Partial // Pages served by SSG eventually benefit from props injected by the MultiversalAppBootstrap component
+ & E;
diff --git a/src/layouts/core/types/SSRPageProps.ts b/src/layouts/core/types/SSRPageProps.ts
new file mode 100644
index 000000000..43b0bfe31
--- /dev/null
+++ b/src/layouts/core/types/SSRPageProps.ts
@@ -0,0 +1,19 @@
+import { MultiversalAppBootstrapPageProps } from '@/app/types/MultiversalAppBootstrapPageProps';
+import { MultiversalPageProps } from './MultiversalPageProps';
+import { OnlyServerPageProps } from './OnlyServerPageProps';
+
+/**
+ * Dynamic (server) properties returned by getInitialProps or getServerProps for server-side rendered pages (using SSR)
+ * Mind that those properties are generated by the server, for each request
+ *
+ * Multiversal page props are listed in MultiversalPageProps
+ * Server-side page props are listed in SSRPageProps
+ * Client-side page props are listed in SSGPageProps
+ */
+export type SSRPageProps = {
+ // Props that are specific to SSR
+ isServerRendering: boolean;
+ isQuickPreviewPage: boolean;
+} & MultiversalPageProps // Generic props that are provided immediately, no matter what
+ & Partial // Pages served by SSR eventually benefit from props injected by the MultiversalAppBootstrap component
+ & E;
diff --git a/src/layouts/core/types/SoftPageProps.ts b/src/layouts/core/types/SoftPageProps.ts
new file mode 100644
index 000000000..2d2981513
--- /dev/null
+++ b/src/layouts/core/types/SoftPageProps.ts
@@ -0,0 +1,20 @@
+import { MultiversalPageProps } from './MultiversalPageProps';
+import { OnlyBrowserPageProps } from './OnlyBrowserPageProps';
+import { OnlyServerPageProps } from './OnlyServerPageProps';
+
+/**
+ * Generic helper meant to be used in pages, when you don't want to use a strict typing
+ * Extends all common properties whether they're multiversal or specific to either browser or server
+ *
+ * Avoid pointless TS warnings when manipulating server-only or browser-only props
+ * Meant to help developers to avoid struggling with TS types
+ *
+ * Alternatively, you can use "MultiversalPageProps" which will ensure stricter types checks
+ *
+ * XXX When using this type, you must make sure you're using the right runtime engine (browser/server)
+ * For instance, it'll allow to use browser-only props like "isInIframe" without complaining, but you should provide a proper default if not set
+ */
+export type SoftPageProps =
+ MultiversalPageProps &
+ Partial &
+ Partial;
diff --git a/src/layouts/demo/components/BuiltInFeaturesSection.tsx b/src/layouts/demo/components/BuiltInFeaturesSection.tsx
new file mode 100644
index 000000000..73d668943
--- /dev/null
+++ b/src/layouts/demo/components/BuiltInFeaturesSection.tsx
@@ -0,0 +1,309 @@
+import Btn from '@/components/dataDisplay/Btn';
+import Cards from '@/components/dataDisplay/Cards';
+import ExternalLink from '@/components/dataDisplay/ExternalLink';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import React from 'react';
+import {
+ Alert,
+ Card,
+ CardBody,
+ CardSubtitle,
+ CardText,
+ CardTitle,
+} from 'reactstrap';
+import DemoSection from './DemoSection';
+
+type Props = {}
+
+/**
+ * Documentation section that showcases features that are built-in into NRN
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const BuiltInFeaturesSection: React.FunctionComponent = (props): JSX.Element => {
+ return (
+
+
Built-in features
+
+
+
+
+
Hosting on Vercel vendor
+ “Deploy your app online in a breeze”
+
+
+
+ Learn more about the Vercel cloud platform
+
+
+ Learn how to configure and use Vercel
+
+
+ Learn why we chose Vercel
+
+
+
+
+
+
+
+
+
Stages & secrets
+ “How to deal with secrets, using Vercel vendor”
+
+
+
+ Learn more about the "env and stages" concept
+
+
+ Learn how to configure Vercel secrets, using the CLI
+
+
+ Learn more about their usage and differences
+
+
+
+
+
+
+
+
+
Storybook
+ “Build your own Design System”
+
+
+
+ Learn more about Storybook
+
+
+ See the Storybook for this preset
+
+
+
+
+
+
+
+
+
SaaS B2B MST
+ “Multi Single Tenancy for SaaS B2B businesses who need it”
+
+
+ MST is similar to the monorepo design, where the same source code can be used to deploy multiple instances.
+
+
+
+ Learn more about the "tenancy" concept and what MST means
+
+
+
+
+
+
+
+
+
CI/CD
+ “Continuous Integrations and Continuous Deployments made free and easy, using Github Actions”
+
+
+
+ Learn more about the "CI/CD" concept
+
+
+ Learn how to setup CI/CD
+
+
+ See how to bypass automated CI/CD and deploy manually
+
+
+
+
+
+
+
+
+
Static i18n
+ “Content internationalisation using i18next and Locize vendor”
+
+
+
+ Learn more about the "i18n" concept
+
+
+ Learn how to use the "Locize" vendor
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
Monitoring
+ “Realtime app monitoring using Sentry vendor”
+
+
+
+ Learn more about the "Monitoring" concept
+
+
+ Learn how to use the "Sentry" vendor
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
CSS-in-JS
+ “Styling components with Emotion”
+
+
+
+ Learn how to use the "Emotion" library
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
Cookies consent
+ “Cookies consent using CookieConsent OSS library”
+
+
+
+ Learn more about the "Cookie consent" library
+
+
+ Learn more about user consent and its impact on analytics
+
+
+
+
+
+
+
+
+
Analytics
+ “Analytics using Amplitude vendor”
+
+
+
+ Learn more about the "Analytics" concept
+
+
+ Learn how to use the "Amplitude" vendor
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
Testing
+ “Unit tests using Jest and E2E tests using Cypress”
+
+
+
+ Learn more about the "Testing" concept
+
+
+ Learn how to use the "Cypress" library (E2E)
+
+
+
+
+
+
+
+
+
Icons
+ “Icons library using Font-Awesome”
+
+
+
+ See all available FA icons
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
CSS Animations
+ “Animations using Animate.css”
+
+
+
+ See all available animations
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
UI components library
+ “React components using Reactstrap and Bootstrap”
+
+
+
+ See all available Reactstrap components
+
+
+ See components examples
+
+
+
+
+
+
+
+
+
Docs site
+ “Dedicated GitHub pages website, using Jekyll”
+
+
+
+ Learn more about "GitHub pages"
+
+
+ Learn more about "just-the-docs" built-in template
+
+
+ Learn how to use it
+
+
+
+
+
+
+
+ );
+};
+
+export default BuiltInFeaturesSection;
diff --git a/src/layouts/demo/components/BuiltInFeaturesSidebar.tsx b/src/layouts/demo/components/BuiltInFeaturesSidebar.tsx
new file mode 100644
index 000000000..08e7c8167
--- /dev/null
+++ b/src/layouts/demo/components/BuiltInFeaturesSidebar.tsx
@@ -0,0 +1,121 @@
+import { SidebarLink } from '@/modules/core/data/types/SidebarLink';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import map from 'lodash.map';
+import {
+ NextRouter,
+ useRouter,
+} from 'next/router';
+import React from 'react';
+import {
+ Nav,
+ NavItem,
+ NavLink,
+} from 'reactstrap';
+import { SidebarProps } from './DemoLayout';
+import SidebarFooter from './SidebarFooter';
+
+type Props = SidebarProps;
+
+export const BUILT_IN_FEATURES_SIDEBAR_LINKS: SidebarLink[] = [
+ {
+ href: '/demo/built-in-features/hosting',
+ label: 'Hosting',
+ },
+ {
+ href: '/demo/built-in-features/stages-and-secrets',
+ label: 'Stages & secrets',
+ },
+ {
+ href: '/demo/built-in-features/manual-deployments',
+ label: 'CI/CD',
+ },
+ {
+ href: '/demo/built-in-features/static-i18n',
+ label: 'Static i18n',
+ },
+ {
+ href: '/demo/built-in-features/monitoring',
+ label: 'Monitoring',
+ },
+ {
+ href: '/demo/built-in-features/css-in-js',
+ label: 'CSS-in-JS',
+ },
+ {
+ href: '/demo/built-in-features/cookies-consent',
+ label: 'Cookies consent',
+ },
+ {
+ href: '/demo/built-in-features/analytics',
+ label: 'Analytics',
+ },
+ {
+ href: '/demo/built-in-features/icons',
+ label: 'Icons',
+ },
+ {
+ href: '/demo/built-in-features/animations',
+ label: 'CSS Animations',
+ },
+ {
+ href: '/demo/built-in-features/ui-components',
+ label: 'UI components library',
+ },
+ {
+ href: '/demo/built-in-features/docs-site',
+ label: 'Docs site',
+ },
+];
+
+/**
+ * Sidebar meant to be used on all pages related to the "Built-in features" section
+ *
+ * Display all BUILT_IN_FEATURES_SIDEBAR_LINKS towards pages related to this section
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const BuiltInFeaturesSidebar: React.FunctionComponent = (props): JSX.Element => {
+ const { className } = props;
+ const router: NextRouter = useRouter();
+
+ return (
+
+
Built-in features
+
+
+
+
+
+
+
+ );
+};
+
+export default BuiltInFeaturesSidebar;
diff --git a/src/layouts/demo/components/BuiltInUtilitiesSection.tsx b/src/layouts/demo/components/BuiltInUtilitiesSection.tsx
new file mode 100644
index 000000000..539e26161
--- /dev/null
+++ b/src/layouts/demo/components/BuiltInUtilitiesSection.tsx
@@ -0,0 +1,162 @@
+import Btn from '@/components/dataDisplay/Btn';
+import Cards from '@/components/dataDisplay/Cards';
+import ExternalLink from '@/components/dataDisplay/ExternalLink';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import React from 'react';
+import {
+ Card,
+ CardBody,
+ CardSubtitle,
+ CardText,
+ CardTitle,
+} from 'reactstrap';
+import DemoSection from './DemoSection';
+
+type Props = {}
+
+/**
+ * Documentation section that showcases utilities that are built-in into NRN
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const BuiltInUtilitiesSection: React.FunctionComponent = (props): JSX.Element => {
+ return (
+
+
Built-in utilities
+
+
+
+
+
I18nLink component
+ “Help manage i18n links with breeze”
+
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
Hooks
+ “Helpful hooks”
+
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
API
+ “API endpoints”
+
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
Errors handling
+ “Properly handling errors, to provide good UX and system observability”
+
+
+
+ See how errors are handled
+
+
+
+
+
+
+
+
+
Bundle analysis
+ “Know how big your bundle is”
+
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
SVG to React
+ “Convert your SVGs to React components using SVGR”
+
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
Security audit
+ “Run packages security audit using yarn”
+
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
Packages upgrade
+ “Visually upgrade your packages, with confidence”
+
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+
Tracking useless re-renders
+ “Visualise useless re-renders that slow down your app”
+
+
+
+ Learn how to use the React Profiler
+
+
+ See usage examples
+
+
+
+
+
+
+
+
+ );
+};
+
+export default BuiltInUtilitiesSection;
diff --git a/src/layouts/demo/components/BuiltInUtilitiesSidebar.tsx b/src/layouts/demo/components/BuiltInUtilitiesSidebar.tsx
new file mode 100644
index 000000000..6dcc7aa45
--- /dev/null
+++ b/src/layouts/demo/components/BuiltInUtilitiesSidebar.tsx
@@ -0,0 +1,104 @@
+import { SidebarLink } from '@/modules/core/data/types/SidebarLink';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import map from 'lodash.map';
+import {
+ NextRouter,
+ useRouter,
+} from 'next/router';
+import React from 'react';
+import {
+ Nav,
+ NavItem,
+ NavLink,
+} from 'reactstrap';
+import { SidebarProps } from './DemoLayout';
+import SidebarFooter from './SidebarFooter';
+
+type Props = SidebarProps;
+
+export const BUILT_IN_UTILITIES_SIDEBAR_LINKS: SidebarLink[] = [
+ {
+ href: '/demo/built-in-utilities/i18nLink-component',
+ label: 'I18nLink component',
+ },
+ {
+ href: '/demo/built-in-utilities/hooks',
+ label: 'Hooks',
+ },
+ {
+ href: '/demo/built-in-utilities/api',
+ label: 'API',
+ },
+ {
+ href: '/demo/built-in-utilities/errors-handling',
+ label: 'Errors handling',
+ },
+ {
+ href: '/demo/built-in-utilities/bundle-analysis',
+ label: 'Bundle analysis',
+ },
+ {
+ href: '/demo/built-in-utilities/svg-to-react',
+ label: 'SVG to React',
+ },
+ {
+ href: '/demo/built-in-utilities/security-audit',
+ label: 'Security audit',
+ },
+ {
+ href: '/demo/built-in-utilities/tracking-useless-re-renders',
+ label: 'Tracking useless re-renders',
+ },
+];
+
+/**
+ * Sidebar meant to be used on all pages related to the "Built-in utilities" section
+ *
+ * Display all BUILT_IN_FEATURES_SIDEBAR_LINKS towards pages related to this section
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const BuiltInUtilitiesSidebar: React.FunctionComponent = (props): JSX.Element => {
+ const { className } = props;
+ const router: NextRouter = useRouter();
+
+ return (
+
+
Built-in utilities
+
+
+
+
+
+
+
+ );
+};
+
+export default BuiltInUtilitiesSidebar;
diff --git a/src/layouts/demo/components/DemoFooter.tsx b/src/layouts/demo/components/DemoFooter.tsx
new file mode 100644
index 000000000..90a29c7d6
--- /dev/null
+++ b/src/layouts/demo/components/DemoFooter.tsx
@@ -0,0 +1,174 @@
+import { NRN_CO_BRANDING_LOGO_URL } from '@/app/constants';
+import Logo from '@/components/dataDisplay/Logo';
+import { CSSStyles } from '@/modules/core/css/types/CSSStyles';
+import useCustomer from '@/modules/core/data/hooks/useCustomer';
+import { Asset } from '@/modules/core/data/types/Asset';
+import { Customer } from '@/modules/core/data/types/Customer';
+import GraphCMSAsset from '@/modules/core/gql/components/GraphCMSAsset';
+import I18nBtnChangeLocale from '@/modules/core/i18n/components/I18nBtnChangeLocale';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import { SIZE_XS } from '@/utils/logo';
+import {
+ css,
+ useTheme,
+} from '@emotion/react';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+ style?: CSSStyles;
+};
+
+const DemoFooter: React.FunctionComponent = (props) => {
+ const {
+ style,
+ } = props;
+ const { t } = useTranslation();
+ const customer: Customer = useCustomer();
+ const { availableLanguages } = customer;
+ const shouldDisplayI18nButton = availableLanguages?.length > 1;
+ const theme = useTheme();
+ const {
+ backgroundColor,
+ onBackgroundColor,
+ logo,
+ } = theme;
+ const logoSizesMultipliers = [
+ {
+ size: SIZE_XS,
+ multiplier: 1, // We wanna keep the logos in the footer big and visible even on small devices, we've got enough space
+ },
+ ];
+ const copyrightOwner = customer?.label;
+ const currentYear = (new Date()).getFullYear();
+
+ return (
+
+ );
+};
+
+export default DemoFooter;
+
diff --git a/src/layouts/demo/components/DemoHead.tsx b/src/layouts/demo/components/DemoHead.tsx
new file mode 100644
index 000000000..708b8e290
--- /dev/null
+++ b/src/layouts/demo/components/DemoHead.tsx
@@ -0,0 +1,171 @@
+import {
+ NRN_DEFAULT_FONT,
+ NRN_DEFAULT_SERVICE_LABEL,
+} from '@/app/constants';
+import useCustomer from '@/modules/core/data/hooks/useCustomer';
+import { Customer } from '@/modules/core/data/types/Customer';
+import {
+ AllowedVariableFont,
+ fontsBasePath,
+ VariableFontConfig,
+ variableFontsConfig,
+} from '@/modules/core/fonts/fonts';
+import { SUPPORTED_LOCALES } from '@/modules/core/i18n/i18n';
+import { I18nLocale } from '@/modules/core/i18n/types/I18nLocale';
+import NextHead from 'next/head';
+import React from 'react';
+
+export type HeadProps = {
+ /**
+ * Title of the page. (SEO)
+ *
+ * Displayed in the browser tab.
+ */
+ seoTitle?: string;
+
+ /**
+ * Description of the page. (SEO)
+ *
+ * Used as Open Graph and twitter description.
+ */
+ seoDescription?: string;
+
+ /**
+ * Url of the page. (SEO)
+ *
+ * Used as Open Graph url.
+ */
+ seoUrl?: string;
+
+ /**
+ * Image associated with the page. (SEO)
+ *
+ * Used as Open Graph and twitter image.
+ */
+ seoImage?: string;
+
+ /**
+ * Favicon.
+ *
+ * Websites usually use the same favicon for all their pages.
+ */
+ favicon?: string;
+
+ /**
+ * Additional links and scripts HTML elements.
+ *
+ * Can be used to load 3rd party scripts and such.
+ * It is recommended to use a "" as wrapper.
+ */
+ additionalContent?: React.ReactElement;
+}
+
+/**
+ * Custom Next.js Head component.
+ *
+ * Configures SEO, load fonts.
+ *
+ * TODO Fonts should be loaded differently. Lee Robinson (Vercel) has given great talks recently, see https://leerob.io/blog/fonts
+ * TODO SEO should be done differently. See https://github.com/UnlyEd/next-right-now/issues/150
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ *
+ * https://github.com/vercel/next.js#populating-head
+ */
+const DemoHead: React.FunctionComponent = (props): JSX.Element => {
+ const customer: Customer = useCustomer();
+
+ const defaultDescription = 'Flexible production-grade boilerplate with Next.js 9, Vercel and TypeScript. Includes multiple opt-in presets using Storybook, GraphQL, Analytics, CSS-in-JS, Monitoring, End-to-end testing, Internationalization, CI/CD and SaaS B2B multiple single-tenants (monorepo) support';
+ const defaultMetaURL = 'https://github.com/UnlyEd/next-right-now';
+ const defaultMetaImage = customer?.theme?.logo?.url;
+ const defaultFavicon = '/favicon.ico';
+
+ const {
+ seoTitle = NRN_DEFAULT_SERVICE_LABEL,
+ seoDescription = defaultDescription,
+ seoImage = defaultMetaImage,
+ seoUrl = defaultMetaURL,
+ favicon = defaultFavicon,
+ additionalContent = null,
+ } = props;
+ const fontName: AllowedVariableFont = customer?.theme?.fonts || NRN_DEFAULT_FONT;
+ const config: VariableFontConfig = variableFontsConfig.find((config) => config?.fontName === fontName);
+ const format = config?.format || 'woff2';
+ const fontFile = config?.fontFile || `${fontName}-variable-latin.${format}`;
+
+ return (
+
+
+ {seoTitle}
+
+
+
+
+ {/* Preload the font */}
+
+
+ {
+ SUPPORTED_LOCALES.map((supportedLocale: I18nLocale) => {
+ // Google best practice for SEO https://webmasters.googleblog.com/2011/12/new-markup-for-multilingual-content.html
+ // Google accepts relative links for hreflang
+ // See https://stackoverflow.com/questions/28291574/are-relative-links-valid-in-link-rel-alternate-hreflang-tags
+ // See https://webmasters.googleblog.com/2013/04/5-common-mistakes-with-relcanonical.html
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {/* Detect outdated browser and display a popup about how to upgrade to a more recent browser/version */}
+ {/* XXX See public/static/libs/detect-outdated-browser/README.md */}
+ {/*
+ XXX DISABLED because of https://github.com/mikemaccana/outdated-browser-rework/issues/57#issuecomment-620532590
+ TLDR; Display false-positive warnings on embedded browsers (on mobile devices) if they're too old and the user can't do anything about it (e.g: Facebook Chrome, Linkedin Chrome, etc.)
+ */}
+ {/**/}
+ {/**/}
+
+ {
+ additionalContent && (
+ additionalContent
+ )
+ }
+
+
+ );
+};
+
+export default DemoHead;
diff --git a/src/layouts/demo/components/DemoLayout.tsx b/src/layouts/demo/components/DemoLayout.tsx
new file mode 100644
index 000000000..2df67d0c2
--- /dev/null
+++ b/src/layouts/demo/components/DemoLayout.tsx
@@ -0,0 +1,147 @@
+import { SoftPageProps } from '@/layouts/core/types/SoftPageProps';
+import { GenericObject } from '@/modules/core/data/types/GenericObject';
+import DefaultErrorLayout from '@/modules/core/errorHandling/DefaultErrorLayout';
+import { createLogger } from '@/modules/core/logging/logger';
+import PreviewModeBanner from '@/modules/core/previewMode/components/PreviewModeBanner';
+import ErrorPage from '@/pages/_error';
+import {
+ Amplitude,
+ LogOnMount,
+} from '@amplitude/react-amplitude';
+import * as Sentry from '@sentry/node';
+import classnames from 'classnames';
+import {
+ NextRouter,
+ useRouter,
+} from 'next/router';
+import React, { useState } from 'react';
+import DemoFooter from './DemoFooter';
+import DemoHead, { HeadProps } from './DemoHead';
+import DemoNav from './DemoNav';
+import DemoPageContainer from './DemoPageContainer';
+
+const fileLabel = 'layouts/demo/components/DemoLayout';
+const logger = createLogger({
+ fileLabel,
+});
+
+export type SidebarProps = {
+ className: string;
+}
+
+type Props = {
+ children: React.ReactNode;
+ headProps?: HeadProps;
+ pageName: string;
+ Sidebar?: React.FunctionComponent;
+} & SoftPageProps;
+
+/**
+ * Handles the positioning of top-level elements within the page
+ *
+ * It does the following:
+ * - Adds a Nav/Footer component, and the dynamic Next.js "Page" component in between
+ * - Optionally, it can also display a left sidebar (i.e: used within examples sections)
+ * - Automatically track page views (Amplitude)
+ * - Handles errors by displaying the Error page, with the ability to contact technical support (which will send a Sentry User Feedback)
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const DemoLayout: React.FunctionComponent = (props): JSX.Element => {
+ const {
+ children,
+ error,
+ isInIframe = false, // Won't be defined server-side
+ headProps = {},
+ pageName,
+ Sidebar,
+ } = props;
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true); // Todo make default value depend on viewport size
+ const router: NextRouter = useRouter();
+ const isIframeWithFullPagePreview = router?.query?.fullPagePreview === '1';
+
+ Sentry.addBreadcrumb({ // See https://docs.sentry.io/enriching-error-data/breadcrumbs
+ category: fileLabel,
+ message: `Rendering ${fileLabel} for page ${pageName}`,
+ level: Sentry.Severity.Debug,
+ });
+
+ Sentry.configureScope((scope): void => {
+ scope.setTag('fileLabel', fileLabel);
+ });
+
+ return (
+ ({
+ ...inheritedProps,
+ page: {
+ ...inheritedProps.page,
+ name: pageName,
+ },
+ })}
+ >
+
+
+
+
+ {/* Loaded from components/Head - See https://github.com/mikemaccana/outdated-browser-rework */}
+ {/**/}
+
+ {
+ // XXX You may want to enable preview mode during non-production stages only
+ process.env.NEXT_PUBLIC_APP_STAGE !== 'production' && (
+
+ )
+ }
+
+ {
+ (!isInIframe || isIframeWithFullPagePreview) && (
+
+ )
+ }
+
+
+ {
+ // If an error happened, we display it instead of displaying the page
+ // We display a custom error instead of the native Next.js error by providing children (removing children will display the native Next.js error)
+ error ? (
+
+
+
+ ) : (
+
+ {children}
+
+ )
+ }
+
+
+ {
+ (!isInIframe || isIframeWithFullPagePreview) && (
+
+ )
+ }
+
+
+ );
+};
+
+export default DemoLayout;
diff --git a/src/layouts/demo/components/DemoNav.tsx b/src/layouts/demo/components/DemoNav.tsx
new file mode 100644
index 000000000..2f7598ff6
--- /dev/null
+++ b/src/layouts/demo/components/DemoNav.tsx
@@ -0,0 +1,365 @@
+import Tooltip from '@/components/dataDisplay/Tooltip';
+import { BUILT_IN_FEATURES_SIDEBAR_LINKS } from '@/layouts/demo/components/BuiltInFeaturesSidebar';
+import { BUILT_IN_UTILITIES_SIDEBAR_LINKS } from '@/layouts/demo/components/BuiltInUtilitiesSidebar';
+import { NATIVE_FEATURES_SIDEBAR_LINKS } from '@/layouts/demo/components/NativeFeaturesSidebar';
+import {
+ AMPLITUDE_ACTIONS,
+ AMPLITUDE_EVENTS,
+} from '@/modules/core/amplitude/events';
+import { LogEvent } from '@/modules/core/amplitude/types/Amplitude';
+import { Asset } from '@/modules/core/data/types/Asset';
+import { SidebarLink } from '@/modules/core/data/types/SidebarLink';
+import GraphCMSAsset from '@/modules/core/gql/components/GraphCMSAsset';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import useI18n, { I18n } from '@/modules/core/i18n/hooks/useI18n';
+import {
+ isActive,
+ resolveI18nHomePage,
+} from '@/modules/core/i18n/i18nRouter';
+import { Amplitude } from '@amplitude/react-amplitude';
+import {
+ css,
+ useTheme,
+} from '@emotion/react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import classnames from 'classnames';
+import kebabCase from 'lodash.kebabcase';
+import map from 'lodash.map';
+import {
+ NextRouter,
+ useRouter,
+} from 'next/router';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Col,
+ DropdownItem,
+ DropdownMenu,
+ DropdownToggle,
+ Nav as NavStrap,
+ Navbar,
+ NavItem,
+ NavLink,
+ Row,
+ UncontrolledDropdown,
+} from 'reactstrap';
+
+type Props = {};
+
+const DemoNav: React.FunctionComponent = () => {
+ const { t } = useTranslation();
+ const router: NextRouter = useRouter();
+ const theme = useTheme();
+ const { locale }: I18n = useI18n();
+ const {
+ primaryColor,
+ logo,
+ } = theme;
+
+ return (
+
+ {({ logEvent }: { logEvent: LogEvent }): JSX.Element => (
+
+
+
+
+ Visit our general NRN documentation site, built with the Github Pages and Jekyll!
+ This docs website explains the NRN concepts, how to get started and much more!
+ }
+ >
+ {
+ logEvent(AMPLITUDE_EVENTS.OPEN_GITHUB_DOC, {
+ action: AMPLITUDE_ACTIONS.CLICK,
+ });
+ }}
+ >
+
+ {t('nav.githubDocPage.link', 'Documentation')}
+
+
+
+
+
+
+
+
+
+ Visit our Github branch for the current preset and navigate through the source code!}
+ >
+ {
+ logEvent(AMPLITUDE_EVENTS.OPEN_GITHUB, {
+ action: AMPLITUDE_ACTIONS.CLICK,
+ });
+ }}
+ title={''}
+ >
+
+ {t('nav.githubPage.link', 'Github branch')}
+
+
+
+
+
+
+
+
+
+
+ Edit this demo using GraphCMS!
+
+ Email: unly-nrn+contributor@unly.org
+ Password: bbU#Ec2m6FpqU7&
+
+ You can edit anything and play with the various rendering modes (SSG, SSR, etc.), the GraphCMS API is configured to use DRAFT content in priority.
+ This mean that your changes on any published content will be reflected (because changing a published content creates a draft, and that draft is being used).
+ This has been done on purpose, to allow visitors to manipulate the content of the demo and see their changes being reflected immediately.
+ N.B: Please do not change the customer.ref values, as it would break the associated demo, if the ref doesn't match.
+ }
+ >
+ {
+ logEvent(AMPLITUDE_EVENTS.OPEN_ADMIN_SITE, {
+ action: AMPLITUDE_ACTIONS.CLICK,
+ });
+ }}
+ >
+
+ {t('nav.adminSite.link', 'Go to CMS')}
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default DemoNav;
diff --git a/src/layouts/demo/components/DemoPage.tsx b/src/layouts/demo/components/DemoPage.tsx
new file mode 100644
index 000000000..5a4b9fb56
--- /dev/null
+++ b/src/layouts/demo/components/DemoPage.tsx
@@ -0,0 +1,39 @@
+import { css } from '@emotion/react';
+import React, { ReactNode } from 'react';
+
+type Props = {
+ children: ReactNode;
+}
+
+/**
+ * Documentation page
+ *
+ * Basically wraps the children in a white container
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const DemoPage: React.FunctionComponent = (props): JSX.Element => {
+ const { children } = props;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default DemoPage;
diff --git a/src/layouts/demo/components/DemoPageContainer.tsx b/src/layouts/demo/components/DemoPageContainer.tsx
new file mode 100644
index 000000000..77b43c5f5
--- /dev/null
+++ b/src/layouts/demo/components/DemoPageContainer.tsx
@@ -0,0 +1,151 @@
+import {
+ css,
+ useTheme,
+} from '@emotion/react';
+import classnames from 'classnames';
+import React from 'react';
+import { Container } from 'reactstrap';
+import { SidebarProps } from './DemoLayout';
+import SidebarToggle from './SidebarToggle';
+
+type Props = {
+ children: React.ReactNode;
+ isSidebarOpen: boolean;
+ setIsSidebarOpen: (boolean) => void;
+ Sidebar: React.FunctionComponent;
+}
+
+/**
+ * Page wrapper handling the display of the Next.js Page component (as "children").
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const DemoPageContainer: React.FunctionComponent = (props): JSX.Element => {
+ const {
+ children,
+ isSidebarOpen,
+ setIsSidebarOpen,
+ Sidebar,
+ } = props;
+ const { primaryColor } = useTheme();
+
+ const sidebarWidth = 300;
+ const headingTopOffset = 50;
+ const spacingAroundContainers = 20;
+ const containerCss = css`
+ margin-top: ${headingTopOffset}px;
+ margin-bottom: ${headingTopOffset}px;
+ `;
+
+ if (typeof Sidebar === 'undefined') {
+ return (
+
+ {children}
+
+ );
+
+ } else {
+ return (
+
+ );
+ }
+};
+
+export default DemoPageContainer;
diff --git a/src/layouts/demo/components/DemoSection.tsx b/src/layouts/demo/components/DemoSection.tsx
new file mode 100644
index 000000000..3e43d7a86
--- /dev/null
+++ b/src/layouts/demo/components/DemoSection.tsx
@@ -0,0 +1,27 @@
+import { css } from '@emotion/react';
+import React, { ReactNode } from 'react';
+
+type Props = {
+ children: ReactNode;
+}
+
+/**
+ * Section of documentation, meant to be used within a page to visually separate documentation sections
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const DemoSection: React.FunctionComponent = (props): JSX.Element => {
+ const { children } = props;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default DemoSection;
diff --git a/src/layouts/demo/components/ExternalFeaturesSection.tsx b/src/layouts/demo/components/ExternalFeaturesSection.tsx
new file mode 100644
index 000000000..47a441c28
--- /dev/null
+++ b/src/layouts/demo/components/ExternalFeaturesSection.tsx
@@ -0,0 +1,70 @@
+import Btn from '@/components/dataDisplay/Btn';
+import Cards from '@/components/dataDisplay/Cards';
+import ExternalLink from '@/components/dataDisplay/ExternalLink';
+import React from 'react';
+import {
+ Alert,
+ Card,
+ CardBody,
+ CardSubtitle,
+ CardText,
+ CardTitle,
+} from 'reactstrap';
+import DemoSection from './DemoSection';
+
+type Props = {}
+
+/**
+ * Documentation section that showcases features that aren't directly related to NRN
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const ExternalFeaturesSection: React.FunctionComponent = (props): JSX.Element => {
+ return (
+
+
External features
+
+
+ This section showcases features that aren't built-in within NRN.
+ You can consider them as "advanced integration examples".
+
+
+
+
+
+
Backoffice/Admin site
+ “Update NRN demo using GraphCMS”
+
+
+
+ Edit this demo using GraphCMS!
+
+ Email: unly-nrn+contributor@unly.org
+ Password: bbU#Ec2m6FpqU7&
+
+ You can edit anything and play with the various rendering modes (SSG, SSR, etc.), the GraphCMS API is configured to use DRAFT content in priority.
+ This mean that your changes on any published content will be reflected (because changing a published content creates a draft, and that draft is being used).
+ This has been done on purpose, to allow visitors to manipulate the content of the demo and see their changes being reflected immediately.
+ N.B: Please do not change the customer.ref values, as it would break the associated demo, if the ref doesn't match.
+
+
+
+ Changes will be immediately be reflected on SSG pages, when the preview mode is enabled (staging environment).
+ But, they won't be applied on the production demo (for SSG pages), because those pages have been generated at build-time and aren't updated dynamically.
+ On the other hand, SSR pages will always reflect the latest version of the content.
+
+
+
+
+ Go to GraphCMS
+
+
+
+
+
+
+
+ );
+};
+
+export default ExternalFeaturesSection;
diff --git a/src/layouts/demo/components/IntroductionSection.tsx b/src/layouts/demo/components/IntroductionSection.tsx
new file mode 100644
index 000000000..454d90c5e
--- /dev/null
+++ b/src/layouts/demo/components/IntroductionSection.tsx
@@ -0,0 +1,89 @@
+import ExternalLink from '@/components/dataDisplay/ExternalLink';
+import {
+ AMPLITUDE_ACTIONS,
+ AMPLITUDE_EVENTS,
+} from '@/modules/core/amplitude/events';
+import { LogEvent } from '@/modules/core/amplitude/types/Amplitude';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import { css } from '@emotion/react';
+import React from 'react';
+import {
+ Alert,
+ Jumbotron,
+} from 'reactstrap';
+
+type Props = {
+ // XXX Beware when passing down the "logEvent", because it'll use the props attached from the tag it comes from
+ // It's not an issue here, because we don't "supercharge" it with additional event/user properties
+ // But, if we had wanted to do so, we should have used a different component here, and supercharge its properties
+ logEvent: LogEvent;
+}
+
+/**
+ * Introduction documentation section
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const IntroductionSection: React.FunctionComponent = (props): JSX.Element => {
+ const { logEvent } = props;
+
+ return (
+
+
Next Right Now Demo
+
+ This demo uses the preset
+
+ {process.env.NEXT_PUBLIC_NRN_PRESET}
+
+
+
+ logEvent(AMPLITUDE_EVENTS.OPEN_WHAT_IS_PRESET_DOC, {
+ action: AMPLITUDE_ACTIONS.CLICK,
+ })}
+ >
+ What is a preset?
+
+ -
+ logEvent(AMPLITUDE_EVENTS.OPEN_SEE_ALL_PRESETS_DOC, {
+ action: AMPLITUDE_ACTIONS.CLICK,
+ })}
+ >
+ See all presets
+
+
+
+
+
+ Next Right Now (NRN) is a flexible production-grade boilerplate featuring Next.js framework.
+ It can be used as a boilerplate to get started your own project, or as a learning resource.
+
+
+
+ General documentation about NRN is available at
+ https://unlyed.github.io/next-right-now/.
+ The role of this demo is to showcase what's built-in within the selected preset only.
+
+
+
+ Please note that the documentation is hardcoded in English, so don't expect it to change when switching language.
+ Nav/Footer component are localised, as well as dynamic content and i18n examples.
+
+ You can switch locale from the footer or by clicking on{' '}
+ fr or en or en-US.
+
+
+
+ );
+};
+
+export default IntroductionSection;
diff --git a/src/layouts/demo/components/NativeFeaturesSection.tsx b/src/layouts/demo/components/NativeFeaturesSection.tsx
new file mode 100644
index 000000000..7f80ab802
--- /dev/null
+++ b/src/layouts/demo/components/NativeFeaturesSection.tsx
@@ -0,0 +1,245 @@
+import Btn from '@/components/dataDisplay/Btn';
+import Cards from '@/components/dataDisplay/Cards';
+import ExternalLink from '@/components/dataDisplay/ExternalLink';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import React from 'react';
+import {
+ Alert,
+ Card,
+ CardBody,
+ CardSubtitle,
+ CardText,
+ CardTitle,
+} from 'reactstrap';
+import DemoSection from './DemoSection';
+
+type Props = {}
+
+/**
+ * Documentation section that showcases native Next.js features
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const NativeFeaturesSection: React.FunctionComponent = (props): JSX.Element => {
+ return (
+
+
Next.js native features
+
+
+ Only a small subset of Next.js features (rendering modes, mostly) is covered here.
+ Make sure to check the official documentation to learn more about it (routing, etc.)
+
+
+
+
+
+
SSR
+ “Server Side Rendering”
+
+
+ Next.js has always supported SSR, and it was the main way to render pages until
+ v9.3 came out.
+
+
+
+ SSR can be used either through
+
+ getInitialProps
+
+ or
+
+ getServerSideProps
+ .
+
+
+
+
+ Learn more about getServerSideProps
+
+
+ Example with getServerSideProps
+
+
+
+
+ We have decided not to use getInitialProps, because even though it is not officially deprecated, {' '}
+ it is preferred to use getServerSideProps whenever possible.
+
+ From experience, it is harder to use getInitialProps because you have to make your code universal, {' '}
+ because it will be executed from the server (SSR) and the browser (CSR), and it usually leads to increased complexity and tougher bugs.
+
+
+
+
+
+
+
+
SSG
+ “Static Site Generation” / “Server-Side Generated”
+
+
+ Next.js supports SSG since v9.3.
+ This is the officially recommended way to build apps since then.
+
+
+
+ We also strongly recommend using SSG, whenever possible.
+ SSG can only be used using getStaticProps, NRN provides a getCommonStaticProps helper
+ to configure common stuff between all SSG-based pages and reduce code duplication.
+
+
+
+
+ Learn more about getStaticProps
+
+
+ Example with getStaticProps
+
+
+
+
+ SSG can be used with different options, which are described below,{' '}
+ and provide much greater flexibility and business possibilities compared to the basic example above.
+
+
+
+
+
+
+
+
SSG, using fallback option
+ “Pre-build only a subset of your pages, then build static pages on-demand”
+
+
+ Next.js supports SSG with fallback since v9.3.
+
+
+
+ You should use it if you only want/can pre-generate only part of your static pages at build time, and then build static pages on-demand upon request.
+ It's very interesting if you've got a ton of pages and want faster builds. This way, you can pre-build only the most used pages of your app, and build other pages on-demand if they ever get requested.
+
+
+
+
+ Learn more about getStaticProps with fallback option
+
+
+ Example with getStaticProps and fallback
+
+
+
+
+
+
+
+
+
SSG, using revalidate option (BETA)
+ “Automatically rebuild your pages when they get too old, to serve fresh content statically”
+
+
+ Next.js supports SSG with revalidate since
+ v9.4.
+
+
+
+ The revalidate option is strongly related to the Incremental Static Regeneration concept.
+ Basically, it's the ability to regenerate parts of your apps based on your own business rules.
+ Currently, it only supports a time-based regeneration (similar to TTL), thus when your page is too old, it gets regenerated.
+
+
+
+
+ Learn more about getStaticProps with revalidate option
+
+
+ Example with getStaticProps and revalidate
+
+
+
+
+ The RFC is still being discussed, don't hesitate to participate!
+ API-based regeneration (e.g: regenerate pages based on a CMS update) is still being discussed in the RFC.
+
+
+
+
+
+
+
+
TS
+ “TypeScript support and helpful tips”
+
+
+ Next.js supports TS natively out of the box.
+ But, there are special considerations to support TS for testing, code linting, etc.
+
+
+
+ TS is treated as first-class citizen, we do our best to make the TS experience as smooth and simple as possible.
+ We use TS a lot, and type everything. You could say NRN comes with an advanced TS usage.
+ This can be overwhelming for TS beginners, even though you shouldn't have anything to configure by yourself, you'll still need to understand the concepts and be aware of the features we use.
+
+
+
+ We strongly recommend you to take a look at the
+ React TypeScript Cheatsheets
+ , which is amazing for both beginners and experienced TS users.
+
+
+
+
+
+
+
+
New to Next.js?
+ “Step-by-step tutorial for beginners”
+
+
+ Quickly learn the basic concepts of a Next.js app, such as navigation, the different rendering modes, routing, API routes and deploying to Vercel.
+
+
+
+ We strongly recommend doing this tutorial if you're not familiar with Next.js, it'll help you get an overview of what the framework can help you achieve.
+
+
+
+ NRN is meant to help you, but basic Next.js knowledge will be necessary as we don't focus on the basics here but mostly on the difficult/advanced stuff.
+
+
+
+ Go to the tutorial.
+
+
+
+
+
+
+
+
Catch-all routes
+ “Optional catch-all dynamic routes for advanced scenarios”
+
+
+
+ Learn more about "optional catch-all routes" native feature
+
+
+ See usage examples
+
+
+
+
+
+
+
+ );
+};
+
+export default NativeFeaturesSection;
diff --git a/src/layouts/demo/components/NativeFeaturesSidebar.tsx b/src/layouts/demo/components/NativeFeaturesSidebar.tsx
new file mode 100644
index 000000000..25c59f24d
--- /dev/null
+++ b/src/layouts/demo/components/NativeFeaturesSidebar.tsx
@@ -0,0 +1,96 @@
+import { SidebarLink } from '@/modules/core/data/types/SidebarLink';
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import map from 'lodash.map';
+import {
+ NextRouter,
+ useRouter,
+} from 'next/router';
+import React from 'react';
+import {
+ Nav,
+ NavItem,
+ NavLink,
+} from 'reactstrap';
+import { SidebarProps } from './DemoLayout';
+import SidebarFooter from './SidebarFooter';
+
+type Props = SidebarProps;
+
+export const NATIVE_FEATURES_SIDEBAR_LINKS: SidebarLink[] = [
+ {
+ href: '/demo/native-features/example-with-ssr',
+ label: 'SSR (getServerSideProps)',
+ },
+ {
+ href: '/demo/native-features/example-with-ssg',
+ label: 'SSG',
+ },
+ {
+ href: '/demo/native-features/example-with-ssg-and-fallback/[albumId]',
+ label: 'SSG using fallback',
+ params: {
+ albumId: 1,
+ },
+ },
+ {
+ href: '/demo/native-features/example-with-ssg-and-revalidate',
+ label: 'SSG using revalidate',
+ },
+ {
+ href: '/demo/native-features/example-optional-catch-all-routes',
+ label: 'Catch-all routes',
+ },
+];
+
+/**
+ * Sidebar meant to be used on all pages related to the "Native features" section
+ *
+ * Display all NATIVE_FEATURES_SIDEBAR_LINKS towards pages related to this section
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const NativeFeaturesSidebar: React.FunctionComponent = (props): JSX.Element => {
+ const { className } = props;
+ const router: NextRouter = useRouter();
+
+ return (
+
+
Native features
+
+
+
+
+
+
+
+ );
+};
+
+export default NativeFeaturesSidebar;
diff --git a/src/layouts/demo/components/SidebarFooter.tsx b/src/layouts/demo/components/SidebarFooter.tsx
new file mode 100644
index 000000000..a5804ba78
--- /dev/null
+++ b/src/layouts/demo/components/SidebarFooter.tsx
@@ -0,0 +1,101 @@
+import I18nLink from '@/modules/core/i18n/components/I18nLink';
+import { css } from '@emotion/react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+ previousSectionHref?: string; // If not defined, then won't show the previous section link
+ nextSectionHref?: string; // If not defined, then won't show the next section link
+};
+
+const HomePageLink: React.FunctionComponent = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('nav.indexPage.link', 'Accueil')}
+
+ );
+};
+
+const NextSectionLink: React.FunctionComponent<{ nextSectionHref: string }> = (props) => {
+ const { nextSectionHref } = props;
+
+ return (
+
+
+ Next section
+
+ );
+};
+
+const PreviousSectionLink: React.FunctionComponent<{ previousSectionHref: string }> = (props) => {
+ const { previousSectionHref } = props;
+
+ return (
+
+
+ Previous section
+
+ );
+};
+
+/**
+ * Sidebar footer
+ *
+ * Displays a Home link shortcut, and an optional link to go to the next section
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the core implementation, so the demo keeps working even the core implementation changes.
+ */
+const SidebarFooter: React.FunctionComponent = (props): JSX.Element => {
+ const {
+ nextSectionHref,
+ previousSectionHref,
+ } = props;
+
+ return (
+
+ );
+};
+
+export default SidebarToggle;
diff --git a/src/layouts/demo/demoLayoutSSG.ts b/src/layouts/demo/demoLayoutSSG.ts
new file mode 100644
index 000000000..3e29aaec7
--- /dev/null
+++ b/src/layouts/demo/demoLayoutSSG.ts
@@ -0,0 +1,217 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { StaticPath } from '@/app/types/StaticPath';
+import { StaticPathsOutput } from '@/app/types/StaticPathsOutput';
+import { StaticPropsInput } from '@/app/types/StaticPropsInput';
+import { DEMO_LAYOUT_QUERY } from '@/common/gql/demoLayoutQuery';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import {
+ GetDemoLayoutStaticPaths,
+ GetDemoLayoutStaticPathsOptions,
+} from '@/layouts/demo/types/GetDemoLayoutStaticPaths';
+import {
+ GetDemoLayoutStaticProps,
+ GetDemoLayoutStaticPropsOptions,
+} from '@/layouts/demo/types/GetDemoLayoutStaticProps';
+import {
+ APOLLO_STATE_PROP_NAME,
+ getApolloState,
+ initializeApollo,
+} from '@/modules/core/apollo/apolloClient';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+import { getGraphcmsDataset } from '@/modules/core/gql/getGraphcmsDataset';
+import { prepareGraphCMSLocaleHeader } from '@/modules/core/gql/graphcms';
+import {
+ StaticCustomer,
+ StaticDataset,
+} from '@/modules/core/gql/types/StaticDataset';
+import { getLocizeTranslations } from '@/modules/core/i18n/getLocizeTranslations';
+import {
+ DEFAULT_LOCALE,
+ resolveFallbackLanguage,
+} from '@/modules/core/i18n/i18n';
+import { I18nextResources } from '@/modules/core/i18n/i18nextLocize';
+import { createLogger } from '@/modules/core/logging/logger';
+import { PreviewData } from '@/modules/core/previewMode/types/PreviewData';
+import serializeSafe from '@/modules/core/serializeSafe/serializeSafe';
+import {
+ ApolloClient,
+ ApolloQueryResult,
+ NormalizedCacheObject,
+} from '@apollo/client';
+import includes from 'lodash.includes';
+import map from 'lodash.map';
+import {
+ GetStaticPaths,
+ GetStaticPathsContext,
+ GetStaticProps,
+ GetStaticPropsResult,
+} from 'next';
+
+const fileLabel = 'layouts/demo/demoLayoutSSG';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Returns a "getStaticPaths" function.
+ *
+ * @param options
+ */
+export const getDemoLayoutStaticPaths: GetDemoLayoutStaticPaths = (options?: GetDemoLayoutStaticPathsOptions) => {
+ const {
+ fallback = false,
+ } = options || {};
+
+ /**
+ * Only executed on the server side at build time.
+ * Computes all static paths that should be available for all SSG pages.
+ * Necessary when a page has dynamic routes and uses "getStaticProps", in order to build the HTML pages.
+ *
+ * You can use "fallback" option to avoid building all page variants and allow runtime fallback.
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ * Can be overridden for per-page customisation (e.g: deepmerge).
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the baseSSG implementation, so the demo keep working even if you change the base implementation.
+ *
+ * @return Static paths that will be used by "getCoreLayoutStaticProps" to generate pages
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation
+ */
+ const getStaticPaths: GetStaticPaths = async (context: GetStaticPathsContext): Promise => {
+ const lang = DEFAULT_LOCALE;
+ const bestCountryCodes: string[] = [lang, resolveFallbackLanguage(lang)];
+ const gcmsLocales: string = prepareGraphCMSLocaleHeader(bestCountryCodes);
+ const dataset: StaticDataset | GraphCMSDataset = await getGraphcmsDataset(gcmsLocales);
+ const customer: StaticCustomer | Customer = dataset?.customer;
+
+ // Generate only pages for languages that have been allowed by the customer
+ const paths: StaticPath[] = map(customer?.availableLanguages, (availableLanguage: string): StaticPath => {
+ return {
+ params: {
+ locale: availableLanguage,
+ },
+ };
+ });
+
+ return {
+ fallback,
+ paths,
+ };
+ };
+
+ return getStaticPaths;
+};
+
+/**
+ * Returns a "getStaticProps" function.
+ *
+ * Disables redirecting to the 404 page when building the 404 page.
+ *
+ * @param options
+ */
+export const getDemoLayoutStaticProps: GetDemoLayoutStaticProps = (options?: GetDemoLayoutStaticPropsOptions) => {
+ const {
+ enable404Redirect = true,
+ } = options || {};
+
+ /**
+ * Only executed on the server side at build time.
+ * Computes all static props that should be available for all SSG pages.
+ *
+ * Note that when a page uses "getStaticProps", then "_app:getInitialProps" is executed (if defined) but not actually used by the page,
+ * only the results from getStaticProps are actually injected into the page (as "SSGPageProps").
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ * Can be overridden for per-page customisation (e.g: deepmerge).
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the coreLayoutSSG implementation, so the demo keep working even if you change the base implementation.
+
+ * @return Props (as "SSGPageProps") that will be passed to the Page component, as props (known as "pageProps" in _app).
+ *
+ * @see https://github.com/vercel/next.js/discussions/10949#discussioncomment-6884
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation
+ */
+ const getStaticProps: GetStaticProps = async (props: StaticPropsInput): Promise> => {
+ const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const preview: boolean = props?.preview || false;
+ const previewData: PreviewData = props?.previewData || null;
+ const hasLocaleFromUrl = !!props?.params?.locale;
+ const locale: string = hasLocaleFromUrl ? props?.params?.locale : DEFAULT_LOCALE; // If the locale isn't found (e.g: 404 page)
+ const lang: string = locale.split('-')?.[0];
+ const bestCountryCodes: string[] = [lang, resolveFallbackLanguage(lang)];
+ const gcmsLocales: string = prepareGraphCMSLocaleHeader(bestCountryCodes);
+ const i18nTranslations: I18nextResources = await getLocizeTranslations(lang);
+ // XXX This part is not using "getGraphcmsDataset" because I'm not sure how to return the "apolloClient" instance when doing so, as it'll be wrapped and isn't returned
+ // So, code is duplicated, but that works fine
+ const apolloClient: ApolloClient = initializeApollo();
+ const variables = {
+ customerRef,
+ };
+ const queryOptions = {
+ displayName: 'DEMO_LAYOUT_QUERY',
+ query: DEMO_LAYOUT_QUERY,
+ variables,
+ context: {
+ headers: {
+ 'gcms-locales': gcmsLocales,
+ },
+ },
+ };
+
+ const {
+ data,
+ errors,
+ loading,
+ networkStatus,
+ ...rest
+ }: ApolloQueryResult<{
+ customer: Customer;
+ }> = await apolloClient.query(queryOptions);
+
+ if (errors) {
+ // eslint-disable-next-line no-console
+ console.error(errors);
+ throw new Error('Errors were detected in GraphQL query.');
+ }
+
+ const {
+ customer,
+ } = data || {}; // XXX Use empty object as fallback, to avoid app crash when destructuring, if no data is returned
+ const dataset = {
+ customer,
+ };
+
+ // Do not serve pages using locales the customer doesn't have enabled (useful during preview mode and in development env)
+ if (enable404Redirect && !includes(customer?.availableLanguages, locale)) {
+ logger.warn(`Locale "${locale}" not enabled for this customer (allowed: "${customer?.availableLanguages}"), returning 404 page.`);
+
+ return {
+ notFound: true,
+ };
+ }
+
+ return {
+ // Props returned here will be available as page properties (pageProps)
+ props: {
+ [APOLLO_STATE_PROP_NAME]: getApolloState(apolloClient),
+ bestCountryCodes,
+ serializedDataset: serializeSafe(dataset),
+ customer,
+ customerRef,
+ i18nTranslations,
+ gcmsLocales,
+ hasLocaleFromUrl,
+ isReadyToRender: true,
+ isStaticRendering: true,
+ lang,
+ locale,
+ preview,
+ previewData,
+ },
+ };
+ };
+
+ return getStaticProps;
+};
diff --git a/src/layouts/demo/demoLayoutSSR.ts b/src/layouts/demo/demoLayoutSSR.ts
new file mode 100644
index 000000000..455148a65
--- /dev/null
+++ b/src/layouts/demo/demoLayoutSSR.ts
@@ -0,0 +1,162 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { DEMO_LAYOUT_QUERY } from '@/common/gql/demoLayoutQuery';
+import { PublicHeaders } from '@/layouts/core/types/PublicHeaders';
+import { SSRPageProps } from '@/layouts/core/types/SSRPageProps';
+import {
+ GetDemoLayoutServerSideProps,
+ GetDemoLayoutServerSidePropsOptions,
+} from '@/layouts/demo/types/GetDemoLayoutServerSideProps';
+import { initializeApollo } from '@/modules/core/apollo/apolloClient';
+import { Cookies } from '@/modules/core/cookiesManager/types/Cookies';
+import UniversalCookiesManager from '@/modules/core/cookiesManager/UniversalCookiesManager';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+import { getGraphcmsDataset } from '@/modules/core/gql/getGraphcmsDataset';
+import { prepareGraphCMSLocaleHeader } from '@/modules/core/gql/graphcms';
+import { ApolloQueryOptions } from '@/modules/core/gql/types/ApolloQueryOptions';
+import {
+ StaticCustomer,
+ StaticDataset,
+} from '@/modules/core/gql/types/StaticDataset';
+import { getLocizeTranslations } from '@/modules/core/i18n/getLocizeTranslations';
+import {
+ resolveFallbackLanguage,
+ resolveSSRLocale,
+} from '@/modules/core/i18n/i18n';
+import { I18nextResources } from '@/modules/core/i18n/i18nextLocize';
+import { createLogger } from '@/modules/core/logging/logger';
+import { isQuickPreviewRequest } from '@/modules/core/quickPreview/quickPreview';
+import { UserSemiPersistentSession } from '@/modules/core/userSession/types/UserSemiPersistentSession';
+import {
+ ApolloClient,
+ NormalizedCacheObject,
+} from '@apollo/client';
+import { IncomingMessage } from 'http';
+import includes from 'lodash.includes';
+import {
+ GetServerSideProps,
+ GetServerSidePropsContext,
+ GetServerSidePropsResult,
+} from 'next';
+import NextCookies from 'next-cookies';
+
+const fileLabel = 'layouts/demo/demoLayoutSSR';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * "getDemoLayoutServerSideProps" returns only part of the props expected in SSRPageProps.
+ * To avoid TS errors, we omit those that we don't return, and add those necessary to the "getServerSideProps" function.
+ */
+export type GetDemoLayoutServerSidePropsResults = Omit & {
+ apolloClient: ApolloClient;
+ layoutQueryOptions: ApolloQueryOptions;
+ headers: PublicHeaders;
+}
+
+/**
+ * Returns a "getServerSideProps" function.
+ *
+ * Disables redirecting to the 404 page when building the 404 page.
+ *
+ * @param options
+ */
+export const getDemoLayoutServerSideProps: GetDemoLayoutServerSideProps = (options?: GetDemoLayoutServerSidePropsOptions) => {
+ const {
+ enable404Redirect = true,
+ } = options || {};
+
+ /**
+ * Only executed on the server side, for every request.
+ * Computes some dynamic props that should be available for all SSR pages that use getServerSideProps.
+ *
+ * Because the exact GQL query will depend on the consumer (AKA "caller"), this helper doesn't run any query by itself, but rather return all necessary props to allow the consumer to perform its own queries.
+ * This improves performances, by only running one GQL query instead of many (consumer's choice).
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ *
+ * XXX Demo component, not meant to be modified. It's a copy of the coreLayoutSSR implementation, so the demo keep working even if you change the base implementation.
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering
+ */
+ const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext): Promise> => {
+ const {
+ query,
+ params,
+ req,
+ res,
+ ...rest
+ } = context;
+ const isQuickPreviewPage: boolean = isQuickPreviewRequest(req);
+ const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const readonlyCookies: Cookies = NextCookies(context); // Parses Next.js cookies in a universal way (server + client)
+ const cookiesManager: UniversalCookiesManager = new UniversalCookiesManager(req, res); // Cannot be forwarded as pageProps, because contains circular refs
+ const userSession: UserSemiPersistentSession = cookiesManager.getUserData();
+ const { headers }: IncomingMessage = req;
+ const publicHeaders: PublicHeaders = {
+ 'accept-language': headers?.['accept-language'],
+ 'user-agent': headers?.['user-agent'],
+ 'host': headers?.host,
+ };
+ const hasLocaleFromUrl = !!query?.locale;
+ const locale: string = resolveSSRLocale(query, req, readonlyCookies);
+ const lang: string = locale.split('-')?.[0];
+ const bestCountryCodes: string[] = [lang, resolveFallbackLanguage(lang)];
+ const gcmsLocales: string = prepareGraphCMSLocaleHeader(bestCountryCodes);
+ const i18nTranslations: I18nextResources = await getLocizeTranslations(lang);
+ // XXX This part is not using "getGraphcmsDataset" because I'm not sure how to return the "apolloClient" instance when doing so, as it'll be wrapped and isn't returned
+ // So, code is duplicated, but that works fine
+ const apolloClient: ApolloClient = initializeApollo();
+ const variables = {
+ customerRef,
+ };
+ const layoutQueryOptions: ApolloQueryOptions = {
+ displayName: 'DEMO_LAYOUT_QUERY',
+ query: DEMO_LAYOUT_QUERY,
+ variables,
+ context: {
+ headers: {
+ 'gcms-locales': gcmsLocales,
+ },
+ },
+ };
+
+ const dataset: StaticDataset | GraphCMSDataset = await getGraphcmsDataset(gcmsLocales);
+ const customer: StaticCustomer | Customer = dataset?.customer;
+
+ // Do not serve pages using locales the customer doesn't have enabled
+ if (enable404Redirect && !includes(customer?.availableLanguages, locale)) {
+ logger.warn(`Locale "${locale}" not enabled for this customer (allowed: "${customer?.availableLanguages}"), returning 404 page.`);
+
+ return {
+ notFound: true,
+ };
+ }
+
+ // Most props returned here will be necessary for the app to work properly (see "SSRPageProps")
+ // Some props are meant to be helpful to the consumer and won't be passed down to the _app.render (e.g: apolloClient, layoutQueryOptions)
+ return {
+ props: {
+ apolloClient,
+ bestCountryCodes,
+ serializedDataset: null, // We don't send the dataset yet (we don't have any because we haven't fetched the database yet), but it must be done by SSR pages in"getServerSideProps"
+ customerRef,
+ i18nTranslations,
+ headers: publicHeaders,
+ gcmsLocales,
+ hasLocaleFromUrl,
+ isReadyToRender: true,
+ isServerRendering: true,
+ lang,
+ locale,
+ layoutQueryOptions,
+ readonlyCookies,
+ userSession,
+ isQuickPreviewPage,
+ },
+ };
+ };
+
+ return getServerSideProps;
+};
diff --git a/src/layouts/demo/types/GetDemoLayoutServerSideProps.ts b/src/layouts/demo/types/GetDemoLayoutServerSideProps.ts
new file mode 100644
index 000000000..a8c26269f
--- /dev/null
+++ b/src/layouts/demo/types/GetDemoLayoutServerSideProps.ts
@@ -0,0 +1,17 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { GetCoreServerSidePropsOptions } from '@/layouts/core/types/GetCoreLayoutServerSideProps';
+import { GetDemoLayoutServerSidePropsResults } from '@/layouts/demo/demoLayoutSSR';
+import { GetServerSideProps } from 'next';
+
+/**
+ * The getDemoLayoutServerSideProps is a function returning a getServerSideProps function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getServerSideProps" function by providing options.
+ */
+export type GetDemoLayoutServerSideProps = (options?: GetDemoLayoutServerSidePropsOptions) => GetServerSideProps;
+
+/**
+ * Options allowed in GetDemoLayoutServerSideProps function.
+ */
+export type GetDemoLayoutServerSidePropsOptions = GetCoreServerSidePropsOptions;
diff --git a/src/layouts/demo/types/GetDemoLayoutStaticPaths.ts b/src/layouts/demo/types/GetDemoLayoutStaticPaths.ts
new file mode 100644
index 000000000..a1a870d30
--- /dev/null
+++ b/src/layouts/demo/types/GetDemoLayoutStaticPaths.ts
@@ -0,0 +1,16 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { GetCoreLayoutStaticPathsOptions } from '@/layouts/core/types/GetCoreLayoutStaticPaths';
+import { GetStaticPaths } from 'next';
+
+/**
+ * The getDemoLayoutStaticPaths is a function returning a getStaticPaths function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getStaticProps" function by providing options.
+ */
+export type GetDemoLayoutStaticPaths = (options?: GetDemoLayoutStaticPathsOptions) => GetStaticPaths;
+
+/**
+ * Options allowed in GetDemoLayoutStaticPaths function.
+ */
+export type GetDemoLayoutStaticPathsOptions = GetCoreLayoutStaticPathsOptions;
diff --git a/src/layouts/demo/types/GetDemoLayoutStaticProps.ts b/src/layouts/demo/types/GetDemoLayoutStaticProps.ts
new file mode 100644
index 000000000..44de29e9b
--- /dev/null
+++ b/src/layouts/demo/types/GetDemoLayoutStaticProps.ts
@@ -0,0 +1,20 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { GetCoreLayoutStaticPropsOptions } from '@/layouts/core/types/GetCoreLayoutStaticProps';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import { GetStaticProps } from 'next';
+
+/**
+ * The getDemoLayoutStaticProps is a function returning a getStaticProps function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getStaticProps" function by providing options.
+ *
+ * This is necessary for the 404 page, which must never return a { notFound: true } object.
+ * It allows to conditionally return { notFound: true }, and avoid doing so for that particular page.
+ */
+export type GetDemoLayoutStaticProps = (options?: GetDemoLayoutStaticPropsOptions) => GetStaticProps;
+
+/**
+ * Options allowed in GetDemoLayoutStaticProps function.
+ */
+export type GetDemoLayoutStaticPropsOptions = GetCoreLayoutStaticPropsOptions;
diff --git a/src/layouts/public/components/PublicLayout.tsx b/src/layouts/public/components/PublicLayout.tsx
new file mode 100644
index 000000000..1f064636a
--- /dev/null
+++ b/src/layouts/public/components/PublicLayout.tsx
@@ -0,0 +1,40 @@
+import CoreLayout, { Props as CoreLayoutProps } from '@/layouts/core/components/CoreLayout';
+import { createLogger } from '@/modules/core/logging/logger';
+import { css } from '@emotion/react';
+import React from 'react';
+
+const fileLabel = 'layouts/public/components/PublicLayout';
+const logger = createLogger({
+ fileLabel,
+});
+
+type Props = Omit;
+
+/**
+ * Overrides the CoreLayout to adapt it to the Public layout.
+ *
+ * Hides nav, footer and preview banner and applies some custom CSS for demonstration purpose.
+ */
+const PublicLayout: React.FunctionComponent = (props): JSX.Element => {
+ return (
+
+ );
+};
+
+export default PublicLayout;
diff --git a/src/layouts/public/mockedStaticDataset.ts b/src/layouts/public/mockedStaticDataset.ts
new file mode 100644
index 000000000..2ff77addb
--- /dev/null
+++ b/src/layouts/public/mockedStaticDataset.ts
@@ -0,0 +1,25 @@
+import { Customer } from '@/modules/core/data/types/Customer';
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+
+const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+
+/**
+ * This mocked static dataset is used by the "public" layout to start the app with the minimalist amount of data.
+ *
+ * XXX The "Customer" entity represents a "Tenant" in a multi-tenancy system. It's basically the owner of a site.
+ * Each Customer has its own website, with its own data.
+ *
+ * Fields required by NRN to function properly by default (they can be hardcoded if you only have one Customer, or fetched from a DB if you have many)
+ * - 'ref': Identifier of the customer. Use by analytics, monitoring, etc.
+ * - 'availableLanguages': List of languages the website is available in, static pages will be generated for all listed languages.
+ * - '__typename': Must be "Customer". You can rename it if you wish to, but you'll need to adapt the code in various places.
+ *
+ * @see https://unlyed.github.io/next-right-now/concepts/tenancy.html#tenancy-st-mt-ht-and-mst
+ */
+export const mockedStaticDataset: GraphCMSDataset = {
+ customer: {
+ ref: customerRef,
+ availableLanguages: ['en'], // Necessary to generate the static pages and serve SSR pages, for those languages
+ __typename: 'Customer', // Necessary to find the customer object within the mocked dataset
+ } as Customer, // TS casting is necessary because we don't provide all properties
+};
diff --git a/src/layouts/public/pagePublicTemplateSSG.tsx b/src/layouts/public/pagePublicTemplateSSG.tsx
new file mode 100644
index 000000000..802d32b9b
--- /dev/null
+++ b/src/layouts/public/pagePublicTemplateSSG.tsx
@@ -0,0 +1,70 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { OnlyBrowserPageProps } from '@/layouts/core/types/OnlyBrowserPageProps';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import PublicLayout from '@/layouts/public/components/PublicLayout';
+import {
+ getPublicLayoutStaticPaths,
+ getPublicLayoutStaticProps,
+} from '@/layouts/public/publicLayoutSSG';
+import { AMPLITUDE_PAGES } from '@/modules/core/amplitude/events';
+import useCustomer from '@/modules/core/data/hooks/useCustomer';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { createLogger } from '@/modules/core/logging/logger';
+import {
+ GetStaticPaths,
+ GetStaticProps,
+ NextPage,
+} from 'next';
+import React from 'react';
+
+const fileLabel = 'pages/[locale]/public/pagePublicTemplateSSG';
+const logger = createLogger({ // eslint-disable-line no-unused-vars,@typescript-eslint/no-unused-vars
+ fileLabel,
+});
+
+/**
+ * Generates pages for all enabled languages.
+ *
+ * Only executed on the server side at build time.
+ * Necessary when a page has dynamic routes and uses "getStaticProps".
+ */
+export const getStaticPaths: GetStaticPaths = getPublicLayoutStaticPaths();
+
+/**
+ * Fetches mocked data.
+ *
+ * Only executed on the server side at build time.
+ *
+ * @return Props (as "SSGPageProps") that will be passed to the Page component, as props.
+ *
+ * @see https://github.com/vercel/next.js/discussions/10949#discussioncomment-6884
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation
+ */
+export const getStaticProps: GetStaticProps = getPublicLayoutStaticProps();
+
+/**
+ * SSG pages are first rendered by the server (during static bundling).
+ * Then, they're rendered by the client, and gain additional props (defined in OnlyBrowserPageProps).
+ * Because this last case is the most common (server bundle only happens during development stage), we consider it a d.efault.
+ * To represent this behaviour, we use the native Partial TS keyword to make all OnlyBrowserPageProps optional
+ *
+ * Beware props in OnlyBrowserPageProps are not available on the server.
+ */
+type Props = {} & SSGPageProps>;
+
+const PagePublicTemplateSSG: NextPage = (props): JSX.Element => {
+ const customer: Customer = useCustomer();
+
+ return (
+
+
+ This page is a template meant to be duplicated into "/pages", to quickly get started with new Next.js SSG pages.
+
+
+ );
+};
+
+export default PagePublicTemplateSSG;
diff --git a/src/layouts/public/pagePublicTemplateSSR.tsx b/src/layouts/public/pagePublicTemplateSSR.tsx
new file mode 100644
index 000000000..c9226a8f4
--- /dev/null
+++ b/src/layouts/public/pagePublicTemplateSSR.tsx
@@ -0,0 +1,58 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { OnlyBrowserPageProps } from '@/layouts/core/types/OnlyBrowserPageProps';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import { SSRPageProps } from '@/layouts/core/types/SSRPageProps';
+import PublicLayout from '@/layouts/public/components/PublicLayout';
+import {
+ getPublicLayoutServerSideProps,
+ GetPublicLayoutServerSidePropsResults,
+} from '@/layouts/public/publicLayoutSSR';
+import { AMPLITUDE_PAGES } from '@/modules/core/amplitude/events';
+import useCustomer from '@/modules/core/data/hooks/useCustomer';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { createLogger } from '@/modules/core/logging/logger';
+import {
+ GetServerSideProps,
+ NextPage,
+} from 'next';
+import React from 'react';
+
+const fileLabel = 'pages/[locale]/public/pagePublicTemplateSSR';
+const logger = createLogger({ // eslint-disable-line no-unused-vars,@typescript-eslint/no-unused-vars
+ fileLabel,
+});
+
+/**
+ * Fetches mocked data.
+ *
+ * Only executed on the server side at build time.
+ * Necessary when a page has dynamic routes and uses "getStaticProps".
+ */
+export const getServerSideProps: GetServerSideProps = getPublicLayoutServerSideProps();
+
+/**
+ * SSR pages are first rendered by the server
+ * Then, they're rendered by the client, and gain additional props (defined in OnlyBrowserPageProps)
+ * Because this last case is the most common (server bundle only happens during development stage), we consider it a default
+ * To represent this behaviour, we use the native Partial TS keyword to make all OnlyBrowserPageProps optional
+ *
+ * Beware props in OnlyBrowserPageProps are not available on the server
+ */
+type Props = SSRPageProps & SSGPageProps;
+
+const PagePublicTemplateSSR: NextPage = (props): JSX.Element => {
+ const customer: Customer = useCustomer();
+
+ return (
+
+
+ This page is a template meant to be duplicated into "/pages", to quickly get started with new Next.js SSR pages.
+
+
+ );
+};
+
+export default PagePublicTemplateSSR;
diff --git a/src/layouts/public/publicLayoutSSG.ts b/src/layouts/public/publicLayoutSSG.ts
new file mode 100644
index 000000000..792a47d48
--- /dev/null
+++ b/src/layouts/public/publicLayoutSSG.ts
@@ -0,0 +1,154 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { StaticPath } from '@/app/types/StaticPath';
+import { StaticPathsOutput } from '@/app/types/StaticPathsOutput';
+import { StaticPropsInput } from '@/app/types/StaticPropsInput';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import { mockedStaticDataset } from '@/layouts/public/mockedStaticDataset';
+import {
+ GetPublicLayoutStaticPaths,
+ GetPublicLayoutStaticPathsOptions,
+} from '@/layouts/public/types/GetPublicLayoutStaticPaths';
+import {
+ GetPublicLayoutStaticProps,
+ GetPublicLayoutStaticPropsOptions,
+} from '@/layouts/public/types/GetPublicLayoutStaticProps';
+import { APOLLO_STATE_PROP_NAME } from '@/modules/core/apollo/apolloClient';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { getLocizeTranslations } from '@/modules/core/i18n/getLocizeTranslations';
+import { DEFAULT_LOCALE } from '@/modules/core/i18n/i18n';
+import { supportedLocales } from '@/modules/core/i18n/i18nConfig';
+import { I18nextResources } from '@/modules/core/i18n/i18nextLocize';
+import { I18nLocale } from '@/modules/core/i18n/types/I18nLocale';
+import { createLogger } from '@/modules/core/logging/logger';
+import { PreviewData } from '@/modules/core/previewMode/types/PreviewData';
+import serializeSafe from '@/modules/core/serializeSafe/serializeSafe';
+import includes from 'lodash.includes';
+import map from 'lodash.map';
+import {
+ GetStaticPaths,
+ GetStaticPathsContext,
+ GetStaticProps,
+ GetStaticPropsResult,
+} from 'next';
+
+const fileLabel = 'layouts/public/publicLayoutSSG';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Returns a "getStaticPaths" function.
+ *
+ * @param options
+ */
+export const getPublicLayoutStaticPaths: GetPublicLayoutStaticPaths = (options?: GetPublicLayoutStaticPathsOptions) => {
+ const {
+ fallback = false,
+ } = options || {};
+
+ /**
+ * Only executed on the server side at build time.
+ * Computes all static paths that should be available for all SSG pages.
+ * Necessary when a page has dynamic routes and uses "getStaticProps", in order to build the HTML pages.
+ *
+ * You can use "fallback" option to avoid building all page variants and allow runtime fallback.
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ * Can be overridden for per-page customisation (e.g: deepmerge).
+ *
+ * @return Static paths that will be used by "getCoreLayoutStaticProps" to generate pages
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation
+ */
+ const getStaticPaths: GetStaticPaths = async (context: GetStaticPathsContext): Promise => {
+ const paths: StaticPath[] = map(supportedLocales, (supportedLocale: I18nLocale): StaticPath => {
+ return {
+ params: {
+ locale: supportedLocale.name,
+ },
+ };
+ });
+
+ return {
+ fallback,
+ paths,
+ };
+ };
+
+ return getStaticPaths;
+};
+
+/**
+ * Returns a "getStaticProps" function.
+ *
+ * Disables redirecting to the 404 page when building the 404 page.
+ *
+ * @param options
+ */
+export const getPublicLayoutStaticProps: GetPublicLayoutStaticProps = (options?: GetPublicLayoutStaticPropsOptions): GetStaticProps => {
+ const {
+ enable404Redirect = true,
+ } = options || {};
+
+ /**
+ * XXX This layout comes "naked" (mocked data) with the strictest minimal stuff to build new pages.
+ * It doesn't run GraphQL queries, and provides the minimal amount of required data for the page to work.
+ *
+ * Only executed on the server side at build time.
+ * Computes all static props that should be available for all SSG pages.
+ *
+ * Note that when a page uses "getStaticProps", then "_app:getInitialProps" is executed (if defined) but not actually used by the page,
+ * only the results from getStaticProps are actually injected into the page (as "SSGPageProps").
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ * Can be overridden for per-page customisation (e.g: deepmerge).
+ *
+ * @return Props (as "SSGPageProps") that will be passed to the Page component, as props (known as "pageProps" in _app).
+ *
+ * @see https://github.com/vercel/next.js/discussions/10949#discussioncomment-6884
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getstaticprops-static-generation
+ */
+ const getStaticProps: GetStaticProps = async (props: StaticPropsInput): Promise> => {
+ const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const preview: boolean = props?.preview || false;
+ const previewData: PreviewData = props?.previewData || null;
+ const hasLocaleFromUrl = !!props?.params?.locale;
+ const locale: string = hasLocaleFromUrl ? props?.params?.locale : DEFAULT_LOCALE; // If the locale isn't found (e.g: 404 page)
+ const lang: string = locale.split('-')?.[0];
+ const i18nTranslations: I18nextResources = await getLocizeTranslations(lang);
+ const customer: Customer = mockedStaticDataset?.customer;
+
+ // Do not serve pages using locales the customer doesn't have enabled (useful during preview mode and in development env)
+ if (enable404Redirect && !includes(customer?.availableLanguages, locale)) {
+ logger.warn(`Locale "${locale}" not enabled for this customer (allowed: "${customer?.availableLanguages}"), returning 404 page.`);
+
+ return {
+ notFound: true,
+ };
+ }
+
+ return {
+ // Props returned here will be available as page properties (pageProps)
+ props: {
+ [APOLLO_STATE_PROP_NAME]: {}, // Empty Apollo cache
+ bestCountryCodes: [], // We don't need any because we're not calling a GraphQL endpoint using this layout
+ serializedDataset: serializeSafe({
+ customer,
+ }),
+ customer,
+ customerRef,
+ i18nTranslations,
+ gcmsLocales: null,
+ hasLocaleFromUrl,
+ isReadyToRender: true,
+ isStaticRendering: true,
+ lang,
+ locale,
+ preview,
+ previewData,
+ },
+ };
+ };
+
+ return getStaticProps;
+};
diff --git a/src/layouts/public/publicLayoutSSR.ts b/src/layouts/public/publicLayoutSSR.ts
new file mode 100644
index 000000000..87265e36e
--- /dev/null
+++ b/src/layouts/public/publicLayoutSSR.ts
@@ -0,0 +1,135 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { PublicHeaders } from '@/layouts/core/types/PublicHeaders';
+import { SSRPageProps } from '@/layouts/core/types/SSRPageProps';
+import { mockedStaticDataset } from '@/layouts/public/mockedStaticDataset';
+import {
+ GetPublicLayoutServerSideProps,
+ GetPublicLayoutServerSidePropsOptions,
+} from '@/layouts/public/types/GetPublicLayoutServerSideProps';
+import { APOLLO_STATE_PROP_NAME } from '@/modules/core/apollo/apolloClient';
+import { Cookies } from '@/modules/core/cookiesManager/types/Cookies';
+import UniversalCookiesManager from '@/modules/core/cookiesManager/UniversalCookiesManager';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { prepareGraphCMSLocaleHeader } from '@/modules/core/gql/graphcms';
+import { getLocizeTranslations } from '@/modules/core/i18n/getLocizeTranslations';
+import {
+ resolveFallbackLanguage,
+ resolveSSRLocale,
+} from '@/modules/core/i18n/i18n';
+import { I18nextResources } from '@/modules/core/i18n/i18nextLocize';
+import { createLogger } from '@/modules/core/logging/logger';
+import { isQuickPreviewRequest } from '@/modules/core/quickPreview/quickPreview';
+import serializeSafe from '@/modules/core/serializeSafe/serializeSafe';
+import { UserSemiPersistentSession } from '@/modules/core/userSession/types/UserSemiPersistentSession';
+import { IncomingMessage } from 'http';
+import includes from 'lodash.includes';
+import {
+ GetServerSideProps,
+ GetServerSidePropsContext,
+ GetServerSidePropsResult,
+} from 'next';
+import NextCookies from 'next-cookies';
+
+const fileLabel = 'layouts/public/publicLayoutSSR';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * "getDemoLayoutServerSideProps" returns only part of the props expected in SSRPageProps.
+ * To avoid TS errors, we omit those that we don't return, and add those necessary to the "getServerSideProps" function.
+ */
+export type GetPublicLayoutServerSidePropsResults = SSRPageProps & {
+ headers: PublicHeaders;
+}
+
+/**
+ * Returns a "getServerSideProps" function.
+ *
+ * Disables redirecting to the 404 page when building the 404 page.
+ *
+ * @param options
+ */
+export const getPublicLayoutServerSideProps: GetPublicLayoutServerSideProps = (options?: GetPublicLayoutServerSidePropsOptions) => {
+ const {
+ enable404Redirect = true,
+ } = options || {};
+
+ /**
+ * XXX This layout comes "naked" (mocked data) with the strictest minimal stuff to build new pages.
+ * It doesn't run Airtable API requests, and provides the minimal amount of required data for the page to work.
+ *
+ * Only executed on the server side, for every request.
+ * Computes some dynamic props that should be available for all SSR pages that use getServerSideProps.
+ *
+ * Because the exact GQL query will depend on the consumer (AKA "caller"), this helper doesn't run any query by itself, but rather return all necessary props to allow the consumer to perform its own queries.
+ * This improves performances, by only running one GQL query instead of many (consumer's choice).
+ *
+ * Meant to avoid code duplication between pages sharing the same layout.
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#getserversideprops-server-side-rendering
+ */
+ const getServerSideProps: GetServerSideProps = async (context: GetServerSidePropsContext): Promise> => {
+ const {
+ query,
+ params,
+ req,
+ res,
+ ...rest
+ } = context;
+ const isQuickPreviewPage: boolean = isQuickPreviewRequest(req);
+ const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const readonlyCookies: Cookies = NextCookies(context); // Parses Next.js cookies in a universal way (server + client)
+ const cookiesManager: UniversalCookiesManager = new UniversalCookiesManager(req, res); // Cannot be forwarded as pageProps, because contains circular refs
+ const userSession: UserSemiPersistentSession = cookiesManager.getUserData();
+ const { headers }: IncomingMessage = req;
+ const publicHeaders: PublicHeaders = {
+ 'accept-language': headers?.['accept-language'],
+ 'user-agent': headers?.['user-agent'],
+ 'host': headers?.host,
+ };
+ const hasLocaleFromUrl = !!query?.locale;
+ const locale: string = resolveSSRLocale(query, req, readonlyCookies);
+ const lang: string = locale.split('-')?.[0];
+ const bestCountryCodes: string[] = [lang, resolveFallbackLanguage(lang)];
+ const gcmsLocales: string = prepareGraphCMSLocaleHeader(bestCountryCodes);
+ const customer: Customer = mockedStaticDataset?.customer;
+ const i18nTranslations: I18nextResources = await getLocizeTranslations(lang);
+
+ // Do not serve pages using locales the customer doesn't have enabled
+ if (enable404Redirect && !includes(customer?.availableLanguages, locale)) {
+ logger.warn(`Locale "${locale}" not enabled for this customer (allowed: "${customer?.availableLanguages}"), returning 404 page.`);
+
+ return {
+ notFound: true,
+ };
+ }
+
+ // Most props returned here will be necessary for the app to work properly (see "SSRPageProps")
+ // Some props are meant to be helpful to the consumer and won't be passed down to the _app.render (e.g: apolloClient, layoutQueryOptions)
+ return {
+ props: {
+ [APOLLO_STATE_PROP_NAME]: {}, // Empty Apollo cache
+ bestCountryCodes,
+ serializedDataset: serializeSafe({
+ customer,
+ }),
+ customer,
+ customerRef,
+ i18nTranslations,
+ gcmsLocales,
+ headers: publicHeaders,
+ hasLocaleFromUrl,
+ isReadyToRender: true,
+ isServerRendering: true,
+ lang,
+ locale,
+ readonlyCookies,
+ userSession,
+ isQuickPreviewPage,
+ },
+ };
+ };
+
+ return getServerSideProps;
+};
diff --git a/src/layouts/public/types/GetPublicLayoutServerSideProps.ts b/src/layouts/public/types/GetPublicLayoutServerSideProps.ts
new file mode 100644
index 000000000..3a07da1ef
--- /dev/null
+++ b/src/layouts/public/types/GetPublicLayoutServerSideProps.ts
@@ -0,0 +1,23 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { GetPublicLayoutServerSidePropsResults } from '@/layouts/public/publicLayoutSSR';
+import { GetServerSideProps } from 'next';
+
+/**
+ * The getPublicLayoutServerSideProps is a function returning a getServerSideProps function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getServerSideProps" function by providing options.
+ */
+export type GetPublicLayoutServerSideProps = (options?: GetPublicLayoutServerSidePropsOptions) => GetServerSideProps;
+
+/**
+ * Options allowed in GetPublicLayoutServerSideProps function.
+ */
+export type GetPublicLayoutServerSidePropsOptions = {
+ /**
+ * Whether allowing any redirection to a 404 page.
+ *
+ * @default true
+ */
+ enable404Redirect: boolean;
+};
diff --git a/src/layouts/public/types/GetPublicLayoutStaticPaths.ts b/src/layouts/public/types/GetPublicLayoutStaticPaths.ts
new file mode 100644
index 000000000..a0bac724f
--- /dev/null
+++ b/src/layouts/public/types/GetPublicLayoutStaticPaths.ts
@@ -0,0 +1,25 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { GetStaticPaths } from 'next';
+
+/**
+ * The getPublicLayoutStaticPaths is a function returning a getStaticPaths function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getStaticProps" function by providing options.
+ */
+export type GetPublicLayoutStaticPaths = (options?: GetPublicLayoutStaticPathsOptions) => GetStaticPaths;
+
+/**
+ * Options allowed in GetPublicLayoutStaticPaths function.
+ */
+export type GetPublicLayoutStaticPathsOptions = {
+ /**
+ * Enables fallback mode.
+ *
+ * @default false
+ *
+ * @see https://nextjs.org/docs/basic-features/data-fetching#fallback-true
+ * @see https://nextjs.org/docs/basic-features/data-fetching#the-fallback-key-required
+ */
+ fallback: boolean;
+};
diff --git a/src/layouts/public/types/GetPublicLayoutStaticProps.ts b/src/layouts/public/types/GetPublicLayoutStaticProps.ts
new file mode 100644
index 000000000..6f2b57527
--- /dev/null
+++ b/src/layouts/public/types/GetPublicLayoutStaticProps.ts
@@ -0,0 +1,26 @@
+import { CommonServerSideParams } from '@/app/types/CommonServerSideParams';
+import { SSGPageProps } from '@/layouts/core/types/SSGPageProps';
+import { GetStaticProps } from 'next';
+
+/**
+ * The getPublicLayoutStaticProps is a function returning a getStaticProps function.
+ *
+ * The reason behind this choice are flexibility and code re-usability.
+ * It makes it possible to customize the behavior of the core "getStaticProps" function by providing options.
+ *
+ * This is necessary for the 404 page, which must never return a { notFound: true } object.
+ * It allows to conditionally return { notFound: true }, and avoid doing so for that particular page.
+ */
+export type GetPublicLayoutStaticProps = (options?: GetPublicLayoutStaticPropsOptions) => GetStaticProps;
+
+/**
+ * Options allowed in GetPublicLayoutStaticProps function.
+ */
+export type GetPublicLayoutStaticPropsOptions = {
+ /**
+ * Whether allowing any redirection to a 404 page.
+ *
+ * @default true
+ */
+ enable404Redirect: boolean;
+};
diff --git a/src/modules/README.md b/src/modules/README.md
new file mode 100644
index 000000000..70bf6d3b8
--- /dev/null
+++ b/src/modules/README.md
@@ -0,0 +1,14 @@
+Modules
+===
+
+> Check out the [documentation about the folder structure](../README.md#modules-folder)
+
+Summary:
+
+- This folder contains modules.
+- `src/modules/core` contain modules built-in with NRN, so you can easily differentiate your own code with NRN's code.
+- Modules are a way to organize your code, by putting all related files together, instead of splitting them by "kind".
+- You don't need to use modules.
+- You should use modules if you feel like it's the right thing to do.
+- Don't try to convert everything as a module at once, take it slow, discuss with other team members.
+- If you're not sure, use `common` instead, and come back later if needed.
diff --git a/src/modules/core/amplitude/amplitudeBrowserClient.ts b/src/modules/core/amplitude/amplitudeBrowserClient.ts
new file mode 100644
index 000000000..e592d3737
--- /dev/null
+++ b/src/modules/core/amplitude/amplitudeBrowserClient.ts
@@ -0,0 +1,227 @@
+import {
+ AMPLITUDE_ACTIONS,
+ AMPLITUDE_EVENTS,
+} from '@/modules/core/amplitude/events';
+import { GetAmplitudeInstanceProps } from '@/modules/core/amplitude/types/GetAmplitudeInstanceProps';
+import { GenericObject } from '@/modules/core/data/types/GenericObject';
+import { createLogger } from '@/modules/core/logging/logger';
+import {
+ ClientNetworkConnectionType,
+ ClientNetworkInformationSpeed,
+ getClientNetworkConnectionType,
+ getClientNetworkInformationSpeed,
+} from '@/modules/core/networkInformation/networkInformation';
+import * as Sentry from '@sentry/node';
+import { isBrowser } from '@unly/utils';
+import {
+ AmplitudeClient,
+ Identify,
+} from 'amplitude-js';
+import UniversalCookiesManager from '../cookiesManager/UniversalCookiesManager';
+import { UserSemiPersistentSession } from '../userSession/types/UserSemiPersistentSession';
+import { NextWebVitalsMetricsReport } from '../webVitals/types/NextWebVitalsMetricsReport';
+
+const fileLabel = 'module/core/amplitude/amplitude';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Initializes an existing amplitude instance with base configuration shared by all Amplitude events.
+ *
+ * @param amplitudeInstance
+ * @param options
+ */
+export const initAmplitudeInstance = (amplitudeInstance: AmplitudeClient, options: GetAmplitudeInstanceProps): void => {
+ const {
+ customerRef,
+ iframeReferrer,
+ isInIframe,
+ lang,
+ locale,
+ userId,
+ userConsent,
+ networkSpeed,
+ networkConnectionType,
+ } = options;
+ const {
+ isUserOptedOutOfAnalytics,
+ hasUserGivenAnyCookieConsent,
+ } = userConsent;
+
+ // See https://help.amplitude.com/hc/en-us/articles/115001361248#settings-configuration-options
+ // See all JS SDK options https://github.com/amplitude/Amplitude-JavaScript/blob/master/src/options.js
+ amplitudeInstance.init(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY, null, {
+ userId,
+ logLevel: process.env.NEXT_PUBLIC_APP_STAGE === 'production' ? 'DISABLE' : 'WARN',
+ includeGclid: false, // GDPR Enabling this is not GDPR compliant and must not be enabled without explicit user consent - See https://croud.com/blog/news/10-point-gdpr-checklist-digital-advertising/
+ includeReferrer: true, // See https://help.amplitude.com/hc/en-us/articles/215131888#track-referrers
+ includeUtm: true,
+ // @ts-ignore XXX onError should be allowed, see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/42005
+ onError: (error): void => {
+ Sentry.captureException(error);
+ console.error(error); // eslint-disable-line no-console
+ },
+ sameSiteCookie: 'Strict', // 'Strict' | 'Lax' | 'None' - See https://web.dev/samesite-cookies-explained/
+ cookieExpiration: 365, // Expires in 1 year (would fallback to 10 years by default, which isn't GDPR compliant)
+ });
+
+ // Disable analytics tracking entirely if the user has opted-out
+ if (isUserOptedOutOfAnalytics) {
+ amplitudeInstance.setOptOut(true); // If true, then no events will be logged or sent.
+
+ if (process.env.STORYBOOK !== 'true') {
+ logger.info('User has opted-out of analytics tracking.'); // eslint-disable-line no-console
+ }
+ } else {
+ // Re-enable tracking (necessary if it was previously disabled!)
+ amplitudeInstance.setOptOut(false);
+
+ if (process.env.STORYBOOK !== 'true') {
+ logger.info(`User has opted-in into analytics tracking. (Thank you! This helps us make our product better, and we don't track any personal/identifiable data.`); // eslint-disable-line no-console
+ }
+ }
+
+ amplitudeInstance.setVersionName(process.env.NEXT_PUBLIC_APP_VERSION_RELEASE); // e.g: v1.0.0
+
+ /**
+ * Initializes the Amplitude user session.
+ *
+ * We must set all "must-have" properties here (instead of doing it in the "AmplitudeProvider", as userProperties),
+ * because "react-amplitude" would send the next "page-displayed" event BEFORE sending the $identify event,
+ * which would lead to events not containing the user's session.
+ *
+ * We're only doing this when detecting a new session, as it won't be executed multiple times for the same session anyway, and it avoids noise.
+ *
+ * @see https://github.com/amplitude/Amplitude-JavaScript/issues/223 Learn more about "setOnce"
+ */
+ if (amplitudeInstance.isNewSession()) {
+ const visitor: Identify = new amplitudeInstance.Identify();
+ visitor.setOnce('customer.ref', customerRef);
+
+ if (lang) {
+ // DA Helps figuring out if the initial language (auto-detected) is changed afterwards
+ visitor.setOnce('initial_lang', lang);
+ visitor.setOnce('lang', lang);
+ }
+
+ if (locale) {
+ visitor.setOnce('initial_locale', locale);
+ visitor.setOnce('locale', locale);
+ }
+
+ if (isInIframe) {
+ // DA This will help track down the users who discovered our platform because of an iframe
+ visitor.setOnce('initial_iframe', isInIframe);
+ visitor.setOnce('iframe', isInIframe);
+ }
+
+ if (iframeReferrer) {
+ visitor.setOnce('initial_iframeReferrer', iframeReferrer);
+ visitor.setOnce('iframeReferrer', iframeReferrer);
+ }
+
+ visitor.setOnce('initial_networkSpeed', networkSpeed);
+ visitor.setOnce('initial_networkConnectionType', networkConnectionType);
+
+ visitor.setOnce('networkSpeed', networkSpeed);
+ visitor.setOnce('networkConnectionType', networkConnectionType);
+
+ visitor.set('isUserOptedOutOfAnalytics', isUserOptedOutOfAnalytics);
+ visitor.set('hasUserGivenAnyCookieConsent', hasUserGivenAnyCookieConsent);
+
+ amplitudeInstance.identify(visitor); // Send the new identify event to amplitude (updates the user's identity)
+ }
+};
+
+/**
+ * Base properties shared by all events.
+ */
+export const getDefaultEventProperties = (): GenericObject => {
+ const customerRef = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+
+ return {
+ app: {
+ name: process.env.NEXT_PUBLIC_APP_NAME,
+ release: process.env.NEXT_PUBLIC_APP_VERSION_RELEASE,
+ stage: process.env.NEXT_PUBLIC_APP_STAGE,
+ },
+ page: {
+ url: location.href,
+ path: location.pathname,
+ origin: location.origin,
+ name: null, // XXX Will be set by the page (usually through its layout)
+ },
+ customer: {
+ ref: customerRef,
+ },
+ };
+};
+
+/**
+ * Returns a browser-compatible Amplitude instance.
+ *
+ * @param props
+ */
+export const getAmplitudeInstance = (props: GetAmplitudeInstanceProps): AmplitudeClient | null => {
+ if (isBrowser()) {
+ const amplitude = require('amplitude-js'); // eslint-disable-line @typescript-eslint/no-var-requires
+ const amplitudeInstance: AmplitudeClient = amplitude.getInstance();
+
+ initAmplitudeInstance(amplitudeInstance, props);
+
+ return amplitudeInstance;
+
+ } else {
+ return null;
+ }
+};
+
+/**
+ * Initialise Amplitude and send web-vitals metrics report.
+ *
+ * @param report
+ *
+ * @see https://web.dev/vitals/ Essential metrics for a healthy site
+ * @see https://nextjs.org/blog/next-9-4#integrated-web-vitals-reporting
+ */
+export const sendWebVitals = (report: NextWebVitalsMetricsReport): void => {
+ try {
+ const amplitude = require('amplitude-js'); // eslint-disable-line @typescript-eslint/no-var-requires
+ const amplitudeInstance: AmplitudeClient = amplitude.getInstance();
+ const universalCookiesManager = new UniversalCookiesManager();
+ const userData: UserSemiPersistentSession = universalCookiesManager.getUserData();
+ const networkSpeed: ClientNetworkInformationSpeed = getClientNetworkInformationSpeed();
+ const networkConnectionType: ClientNetworkConnectionType = getClientNetworkConnectionType();
+ const customerRef = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+
+ initAmplitudeInstance(amplitudeInstance, {
+ customerRef: customerRef,
+ userId: userData?.id,
+ userConsent: {
+ isUserOptedOutOfAnalytics: false,
+ hasUserGivenAnyCookieConsent: false,
+ },
+ locale: null,
+ lang: null,
+ isInIframe: null,
+ iframeReferrer: null,
+ networkSpeed,
+ networkConnectionType,
+ });
+
+ // Send metrics to our analytics service
+ amplitudeInstance.logEvent(AMPLITUDE_EVENTS.REPORT_WEB_VITALS, {
+ ...getDefaultEventProperties(),
+ report,
+ networkSpeed,
+ networkConnectionType,
+ action: AMPLITUDE_ACTIONS.AUTO,
+ });
+ // eslint-disable-next-line no-console
+ console.debug('report-web-vitals report sent to Amplitude');
+ } catch (e) {
+ Sentry.captureException(e);
+ console.error(e);// eslint-disable-line no-console
+ }
+};
diff --git a/src/modules/core/amplitude/amplitudeServerClient.ts b/src/modules/core/amplitude/amplitudeServerClient.ts
new file mode 100644
index 000000000..a31547381
--- /dev/null
+++ b/src/modules/core/amplitude/amplitudeServerClient.ts
@@ -0,0 +1,92 @@
+import { AMPLITUDE_EVENTS } from '@/modules/core/amplitude/events';
+import { GenericObject } from '@/modules/core/data/types/GenericObject';
+import { createLogger } from '@/modules/core/logging/logger';
+import { init } from '@amplitude/node';
+import { Event } from '@amplitude/types';
+import { LogLevel } from '@amplitude/types/dist/src/logger';
+import { Response } from '@amplitude/types/dist/src/response';
+import * as Sentry from '@sentry/node';
+import { Context } from '@sentry/types';
+import startsWith from 'lodash.startswith';
+import { v1 as uuid } from 'uuid'; // XXX Use v1 for uniqueness - See https://www.sohamkamani.com/blog/2016/10/05/uuid1-vs-uuid4/
+
+const fileLabel = 'modules/core/amplitude/amplitudeServerClient';
+const logger = createLogger({
+ fileLabel,
+});
+
+const amplitudeServerClient = init(process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY, {
+ debug: process.env.NEXT_PUBLIC_APP_STAGE !== 'production',
+ logLevel: process.env.NEXT_PUBLIC_APP_STAGE !== 'production' ? LogLevel.Verbose : LogLevel.Error,
+});
+
+/**
+ * Sends an analytic event to Amplitude.
+ *
+ * XXX Do not use it in Next.js pages, only in the API (it's not universal and won't work in the browser!).
+ *
+ * XXX It is necessary to ALWAYS await for "logEvent()" call on the server or API endpoints, as the event seem to NOT be ingested correctly when not waiting for the response (would return "Timeout" response).
+ * See https://github.com/vercel/next.js/discussions/26523
+ * See https://github.com/amplitude/Amplitude-Node/issues/123#issuecomment-866278069
+ *
+ * @param eventName
+ * @param userId
+ * @param props
+ * @param sendImmediately
+ *
+ * @see https://developers.amplitude.com/docs/nodejs
+ * @see https://www.npmjs.com/package/@amplitude/node
+ */
+export const logEvent = async (eventName: AMPLITUDE_EVENTS, userId: string, props: GenericObject = {}, sendImmediately = true): Promise => {
+ try {
+ logger.info(`Logging Amplitude event "${eventName}"${userId ? ` for user "${userId}"` : ''} with properties:`, props);
+
+ const event: Event = {
+ event_type: eventName,
+ // Either user_id or device_id must be set, they can't both be empty or "unknown" or "eventsIngested" will be set to 0 in the response
+ // If the user_id isn't defined, then generate a dynamic/unique id for this event only
+ user_id: userId || uuid(),
+ event_properties: {
+ 'customer.ref': process.env.NEXT_PUBLIC_CUSTOMER_REF,
+ ...props,
+ },
+ };
+
+ amplitudeServerClient.logEvent(event)
+ // .then((res) => logger.info('response', res))
+ .catch((e) => logger.error(e));
+
+ if (sendImmediately) {
+ // Send any events that are currently queued for sending
+ // Will automatically happen on the next event loop
+ const response: Response = await amplitudeServerClient.flush();
+
+ // Monitor non 2XX response codes to allow for advanced debugging of edge cases
+ if (!startsWith(response?.statusCode?.toString(10), '2')) {
+ const message = `Amplitude event didn't return 200 response for event "${eventName}".`;
+ logger.error(message, response);
+ Sentry.withScope((scope) => {
+ scope.setContext('event', event as unknown as Context);
+ scope.setContext('response', response);
+ Sentry.captureException(message);
+ });
+ } else {
+ // @ts-ignore
+ const eventsIngested = response?.body?.eventsIngested;
+
+ if (!eventsIngested) {
+ // See https://github.com/amplitude/Amplitude-Node/issues/123
+ const message = `Amplitude event wasn't ingested (it was sent, but not stored in Amplitude), expected value "eventsIngested >= 1" and got "eventsIngested=${eventsIngested}". This usually happens when both user_id and device_id are invalid, they can't both be empty/undefined or 'unknown'!`;
+ logger.error(message, response);
+ Sentry.withScope((scope) => {
+ scope.setContext('response', response);
+ Sentry.captureException(message);
+ });
+ }
+ }
+ }
+ } catch (e) {
+ logger.error(e);
+ Sentry.captureException(e);
+ }
+};
diff --git a/src/modules/core/amplitude/context/amplitudeContext.tsx b/src/modules/core/amplitude/context/amplitudeContext.tsx
new file mode 100644
index 000000000..3e302d037
--- /dev/null
+++ b/src/modules/core/amplitude/context/amplitudeContext.tsx
@@ -0,0 +1,34 @@
+import { AmplitudeClient } from 'amplitude-js';
+import React from 'react';
+
+/**
+ * The AmplitudeContext contains amplitude-related properties
+ *
+ * @see https://stackoverflow.com/a/40076355/2391795
+ * @see https://github.com/Microsoft/TypeScript/blob/ee25cdecbca49b2b5a290ecd65224f425b1d6a9c/lib/lib.es5.d.ts#L1354
+ */
+export type AmplitudeContext = {
+ // We need to access the amplitudeInstance initialised in BrowserPageBootstrap, in other parts of the app
+ amplitudeInstance?: AmplitudeClient;
+}
+
+/**
+ * Initial context, used by default until the Context Provider is initialised.
+ *
+ * @default Empty object, to allow for destructuring even when the context hasn't been initialised (on the server)
+ */
+const initialContext = {};
+
+/**
+ * Uses native React Context API, meant to be used from hooks only, not by functional components
+ *
+ * @example Usage
+ * import amplitudeContext from './src/stores/amplitudeContext';
+ * const { amplitudeInstance }: AmplitudeContext = React.useContext(amplitudeContext);
+ *
+ * @see https://reactjs.org/docs/context.html
+ * @see https://medium.com/better-programming/react-hooks-usecontext-30eb560999f for useContext hook example (open in anonymous browser #paywall)
+ */
+export const amplitudeContext = React.createContext(initialContext);
+
+export default amplitudeContext;
diff --git a/src/modules/core/amplitude/events.ts b/src/modules/core/amplitude/events.ts
new file mode 100644
index 000000000..cda576d72
--- /dev/null
+++ b/src/modules/core/amplitude/events.ts
@@ -0,0 +1,68 @@
+/**
+ * Event names.
+ */
+export enum AMPLITUDE_EVENTS {
+ REPORT_WEB_VITALS = 'report-web-vitals', // When the Core Web Vitals report is sent automatically on any page load
+ USER_CONSENT_MANUALLY_GIVEN = 'user-consent-manually-given', // When the user makes a manual choice regarding cookies consent
+ OPEN_WHAT_IS_PRESET_DOC = 'open-what-is-preset-doc', // When the user clicks on "What is a preset?" link
+ OPEN_SEE_ALL_PRESETS_DOC = 'open-see-all-presets-doc', // When the user clicks on "See all presets" link
+ OPEN_GITHUB_DOC = 'open-github-doc', // When the user clicks on the NRN documentation link
+ OPEN_GITHUB = 'open-github', // When the user clicks on the GitHub link
+ OPEN_ADMIN_SITE = 'open-admin-site', // When the user clicks on the "Go to CMS" link
+ ANALYTIC_BUTTON_TEST_EVENT = 'analytics-button-test-event', // Test event for demo purpose
+ API_INVOKED = 'api-invoked', // When any API is invoked
+ API_LOCALE_MIDDLEWARE_INVOKED = 'api-locale-middleware-invoked', // When the "localeMiddleware" API is invoked
+}
+
+/**
+ * Event actions.
+ *
+ * We use an "action" property to track the event's trigger.
+ * It's especially useful when the same event can be triggered by different actions,
+ * as sometimes it's easier to keep a single event with different properties. (it really depends how you want to use the data)
+ *
+ * Best practice: All actions must use action verb (imperative form).
+ * This is a NRN internal rule (recommandation) about how to track which action led to triggering the event.
+ *
+ * DA Usefulness: Avoids using anonymous constants that will likely end up being duplicated.
+ * Using constants ensures strict usage with a proper definition for the analytics team and the developers.
+ * Example: Using both "remove" and "delete" could lead to misunderstanding or errors when configuring charts.
+ */
+export enum AMPLITUDE_ACTIONS {
+ CLICK = 'click', // When an element is clicked (mouse) or tapped (screen, mobile)
+ SELECT = 'select', // When an element is selected (checkbox, select input, multi choices)
+ REMOVE = 'remove', // When an element is removed/delete
+ OPEN = 'open', // When an element is opened
+ CLOSE = 'close', // When an element is closed
+ AUTO = 'auto', // When an event is triggered automatically instead of a user action
+}
+
+/**
+ * Pages names used within Amplitude.
+ *
+ * Each page within the /src/pages directory should use a different page name as "pageName".
+ * This is used to track events happening within the pages, to know on which page they occurred.
+ */
+export enum AMPLITUDE_PAGES {
+ DEMO_HOME_PAGE = 'demo',
+ PREVIEW_PRODUCT_PAGE = 'demo/preview-product',
+ TERMS_PAGE = 'demo/terms',
+ PRIVACY_PAGE = 'demo/privacy',
+ TEMPLATE_SSG_PAGE = 'template-ssg',
+ TEMPLATE_SSR_PAGE = 'template-ssr',
+}
+
+/**
+ * API endpoint names.
+ *
+ * Each API endpoint within the src/pages/api directory should use a different endpoint name.
+ * This is used to track events happening within the API endpoints, to know on which endpoint they occurred.
+ */
+export enum AMPLITUDE_API_ENDPOINTS {
+ STATUS = 'status',
+ AUTO_REDIRECT_TO_LOCALISED_PAGE = 'autoRedirectToLocalisedPage',
+ START_VERCEL_DEPLOYMENT = 'startVercelDeployment',
+ ERROR = 'error',
+ PREVIEW = 'preview',
+ WEBHOOK_DEPLOYMENT_COMPLETED = 'deploymentCompleted',
+}
diff --git a/src/modules/core/amplitude/hooks/useAmplitude.tsx b/src/modules/core/amplitude/hooks/useAmplitude.tsx
new file mode 100644
index 000000000..f55f134b1
--- /dev/null
+++ b/src/modules/core/amplitude/hooks/useAmplitude.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import amplitudeContext, { AmplitudeContext } from '../context/amplitudeContext';
+
+export type Amplitude = AmplitudeContext
+
+/**
+ * Hook to access amplitude data
+ *
+ * Uses amplitudeContext internally (provides an identical API)
+ *
+ * This hook should be used by components in favor of amplitudeContext directly,
+ * because it grants higher flexibility if you ever need to change the implementation (e.g: use something else than React.Context, like Redux/MobX/Recoil)
+ *
+ * @see https://slides.com/djanoskova/react-context-api-create-a-reusable-snackbar#/11
+ */
+const useAmplitude = (): Amplitude => {
+ return React.useContext(amplitudeContext);
+};
+
+export default useAmplitude;
diff --git a/src/modules/core/amplitude/types/Amplitude.ts b/src/modules/core/amplitude/types/Amplitude.ts
new file mode 100644
index 000000000..7f0d0ab11
--- /dev/null
+++ b/src/modules/core/amplitude/types/Amplitude.ts
@@ -0,0 +1,6 @@
+import {
+ Callback,
+ LogReturn,
+} from 'amplitude-js';
+
+export type LogEvent = (event: string, data?: any, callback?: Callback) => LogReturn;
diff --git a/src/modules/core/amplitude/types/GetAmplitudeInstanceProps.ts b/src/modules/core/amplitude/types/GetAmplitudeInstanceProps.ts
new file mode 100644
index 000000000..12297fd9e
--- /dev/null
+++ b/src/modules/core/amplitude/types/GetAmplitudeInstanceProps.ts
@@ -0,0 +1,20 @@
+import {
+ ClientNetworkConnectionType,
+ ClientNetworkInformationSpeed,
+} from '@/modules/core/networkInformation/networkInformation';
+import { UserConsent } from '@/modules/core/userConsent/types/UserConsent';
+
+/**
+ * Properties necessary to initialize a new Amplitude instance.
+ */
+export type GetAmplitudeInstanceProps = {
+ customerRef: string;
+ iframeReferrer: string;
+ isInIframe?: boolean;
+ lang?: string;
+ locale?: string;
+ userId?: string;
+ userConsent: UserConsent;
+ networkSpeed: ClientNetworkInformationSpeed;
+ networkConnectionType: ClientNetworkConnectionType;
+}
diff --git a/src/modules/core/api/convertRequestBodyToJSObject.ts b/src/modules/core/api/convertRequestBodyToJSObject.ts
new file mode 100644
index 000000000..a31a33055
--- /dev/null
+++ b/src/modules/core/api/convertRequestBodyToJSObject.ts
@@ -0,0 +1,24 @@
+import { NextApiRequest } from 'next';
+import { GenericObject } from '../data/types/GenericObject';
+
+/**
+ * Parse the request body if it's a string, or return the body as-it if it's not.
+ *
+ * Simplifies the handling of "body" from our APIs, by insuring a consistant way of dealing with the request body.
+ * This way, it doesn't matter if data are sent using proper headers (Content-Type) or not.
+ *
+ * @param req
+ */
+export const convertRequestBodyToJSObject = (req: NextApiRequest): GenericObject => {
+ let parsedBody: GenericObject = {};
+
+ if (typeof req?.body === 'string' && req?.body?.length > 0) {
+ parsedBody = JSON.parse(req?.body);
+ } else {
+ parsedBody = req.body;
+ }
+
+ return parsedBody;
+};
+
+export default convertRequestBodyToJSObject;
diff --git a/src/modules/core/api/fetchJSON.test.ts b/src/modules/core/api/fetchJSON.test.ts
new file mode 100644
index 000000000..23e292c57
--- /dev/null
+++ b/src/modules/core/api/fetchJSON.test.ts
@@ -0,0 +1,13 @@
+import fetchJSON from './fetchJSON';
+
+/**
+ * @group unit
+ * @group utils
+ */
+describe(`utils/api/fetchJSON.ts`, () => {
+ describe(`fetchJSON`, () => {
+ test(`should have a globally available "fetch" object (polyfilled from "./jest.setup.js")`, async () => {
+ expect(fetch).toBeDefined();
+ });
+ });
+});
diff --git a/src/modules/core/api/fetchJSON.ts b/src/modules/core/api/fetchJSON.ts
new file mode 100644
index 000000000..27a93eae4
--- /dev/null
+++ b/src/modules/core/api/fetchJSON.ts
@@ -0,0 +1,32 @@
+/**
+ * Uses built-in Next.js "fetch" lib and attempt to transform response as JSON
+ *
+ * Meant to be used from the server side.
+ * Fetching from the client side should rather use SWR hook.
+ *
+ * @example Server-side use "fetchJSON"
+ * @see https://github.com/vercel/swr/blob/master/examples/server-render/pages/%5Bpokemon%5D.js#L40
+ *
+ * @example Client-side use "useSWR" with "initialData" pre-fetched from getServerSideProps
+ * @see https://github.com/vercel/swr/blob/master/examples/server-render/pages/%5Bpokemon%5D.js#L9
+ *
+ * @param args
+ * @see https://nextjs.org/blog/next-9-4#improved-built-in-fetch-support
+ */
+const fetchJSON: (
+ input: RequestInfo,
+ init?: RequestInit,
+) => Promise = async (...args) => {
+ const res = await fetch(...args);
+ if (!res.ok) {
+ let errorMessage = res.statusText;
+ try {
+ errorMessage += ` - ${JSON.stringify(await res.json(), null, 2)}`;
+ } finally {
+ throw new Error(errorMessage);
+ }
+ }
+ return res.json();
+};
+
+export default fetchJSON;
diff --git a/src/modules/core/apollo/apolloClient.ts b/src/modules/core/apollo/apolloClient.ts
new file mode 100644
index 000000000..be6aa00b8
--- /dev/null
+++ b/src/modules/core/apollo/apolloClient.ts
@@ -0,0 +1,106 @@
+import {
+ ApolloClient,
+ ApolloLink,
+ HttpLink,
+ InMemoryCache,
+ NormalizedCacheObject,
+} from '@apollo/client';
+import merge from 'deepmerge';
+import isEqual from 'lodash.isequal';
+import { useMemo } from 'react';
+
+export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__';
+
+export type ApolloState = {
+ [APOLLO_STATE_PROP_NAME]: NormalizedCacheObject;
+};
+
+let apolloClient: ApolloClient;
+
+/**
+ * Create a new apollo client instance.
+ *
+ * @returns {ApolloClient}
+ */
+function createApolloClient(): ApolloClient {
+ const httpLink: ApolloLink = new HttpLink({
+ uri: process.env.GRAPHQL_API_ENDPOINT, // Server URL (must be absolute)
+ // Headers applied here will be applied for all requests
+ // See the use of the "options" when running a graphQL query to specify options per-request at https://www.apollographql.com/docs/react/api/react-hooks/#options
+ headers: {
+ authorization: `Bearer ${process.env.GRAPHQL_API_KEY}`,
+ },
+ credentials: 'same-origin', // XXX See https://www.apollographql.com/docs/react/recipes/authentication#cookie
+ });
+
+ return new ApolloClient({
+ ssrMode: typeof window === 'undefined',
+ link: httpLink,
+ cache: new InMemoryCache(),
+ });
+}
+
+/**
+ * Initiate apollo based on the environment (client or server).
+ *
+ * @param initialState
+ * @returns {ApolloClient}
+ */
+export function initializeApollo(initialState = null): ApolloClient {
+ const client = apolloClient ?? createApolloClient();
+
+ // If your page has Next.js data fetching methods that use Apollo Client, the initial state
+ // gets hydrated here
+ if (initialState) {
+ // Get existing cache, loaded during client side data fetching
+ const existingCache = client.extract();
+
+ // Merge the existing cache into data passed from getStaticProps/getServerSideProps
+ const data = merge(initialState, existingCache, {
+ // combine arrays using object equality (like in sets)
+ arrayMerge: (destinationArray, sourceArray) => [
+ ...sourceArray,
+ ...destinationArray.filter((d) =>
+ sourceArray.every((s) => !isEqual(d, s)),
+ ),
+ ],
+ });
+
+ // Restore the cache with the merged data
+ client.cache.restore(data);
+ }
+
+ // For SSG and SSR always create a new Apollo Client
+ if (typeof window === 'undefined') {
+ return client;
+ }
+
+ // Create the Apollo Client once in the client
+ if (!apolloClient) {
+ apolloClient = client;
+ }
+
+ return client;
+}
+
+/**
+ * Returns the apollo state.
+ *
+ * @param {ApolloClient} client
+ * @returns {NormalizedCacheObject}
+ */
+export function getApolloState(client: ApolloClient): NormalizedCacheObject {
+ return client.cache.extract();
+}
+
+/**
+ * Returns an instance of apollo client.
+ *
+ * @param {PageProps} pageProps
+ * @returns {ApolloClient}
+ */
+export function useApollo(pageProps: T): ApolloClient {
+ const state = pageProps[APOLLO_STATE_PROP_NAME];
+ const store = useMemo(() => initializeApollo(state), [state]);
+ return store;
+}
diff --git a/src/utils/UniversalCookiesManager.browser.test.ts b/src/modules/core/cookiesManager/UniversalCookiesManager.browser.test.ts
similarity index 93%
rename from src/utils/UniversalCookiesManager.browser.test.ts
rename to src/modules/core/cookiesManager/UniversalCookiesManager.browser.test.ts
index 0c9a502e7..00b84f25d 100644
--- a/src/utils/UniversalCookiesManager.browser.test.ts
+++ b/src/modules/core/cookiesManager/UniversalCookiesManager.browser.test.ts
@@ -2,11 +2,15 @@
* @jest-environment jsdom
*/
-import { UserSemiPersistentSession } from '../types/UserSemiPersistentSession';
+import { UserSemiPersistentSession } from '../userSession/types/UserSemiPersistentSession';
import { deleteAllCookies } from './cookies';
import UniversalCookiesManager from './UniversalCookiesManager';
-describe(`utils/UniversalCookiesManager.ts`, () => {
+/**
+ * @group unit
+ * @group utils
+ */
+describe(`utils/cookies/UniversalCookiesManager.ts`, () => {
describe(`browser`, () => {
beforeEach(() => {
deleteAllCookies(); // Reset cookies between each test to avoid "overflow"
@@ -16,9 +20,9 @@ describe(`utils/UniversalCookiesManager.ts`, () => {
test(`should init correctly (no arg)`, async () => {
const universalCookiesManager = new UniversalCookiesManager();
- // @ts-ignore
+ // @ts-expect-error
expect(universalCookiesManager.req).toEqual(null);
- // @ts-ignore
+ // @ts-expect-error
expect(universalCookiesManager.res).toEqual(null);
});
});
@@ -80,7 +84,7 @@ describe(`utils/UniversalCookiesManager.ts`, () => {
expect(userSessionPatched.id).toBeDefined();
expect(userSessionPatched.deviceId).toBeDefined();
- // @ts-ignore
+ // @ts-expect-error
expect(document.cookie).toEqual(`user={"id":"${userSessionPatched.id}","deviceId":"${userSessionPatched.deviceId}","persona":"${userSessionPatched.persona}"}`);
});
});
diff --git a/src/utils/UniversalCookiesManager.server.test.ts b/src/modules/core/cookiesManager/UniversalCookiesManager.server.test.ts
similarity index 82%
rename from src/utils/UniversalCookiesManager.server.test.ts
rename to src/modules/core/cookiesManager/UniversalCookiesManager.server.test.ts
index 67334729f..5a7f640db 100644
--- a/src/utils/UniversalCookiesManager.server.test.ts
+++ b/src/modules/core/cookiesManager/UniversalCookiesManager.server.test.ts
@@ -1,11 +1,14 @@
import httpMocks from 'node-mocks-http';
-
import UniversalCookiesManager from './UniversalCookiesManager';
// TODO Couldn't mock server correctly in a way that is compatible with how "cookies" works
// Needs more tests (browser is properly tested, but not server)
-describe(`utils/UniversalCookiesManager.ts`, () => {
+/**
+ * @group unit
+ * @group utils
+ */
+describe(`utils/cookies/UniversalCookiesManager.ts`, () => {
describe(`server`, () => {
describe(`constructor`, () => {
test(`should init correctly (req, res)`, async () => {
@@ -13,9 +16,9 @@ describe(`utils/UniversalCookiesManager.ts`, () => {
const res = httpMocks.createResponse();
const universalCookiesManager = new UniversalCookiesManager(req, res);
- // @ts-ignore
+ // @ts-expect-error
expect(universalCookiesManager.req).toBeDefined();
- // @ts-ignore
+ // @ts-expect-error
expect(universalCookiesManager.res).toBeDefined();
});
});
diff --git a/src/utils/UniversalCookiesManager.ts b/src/modules/core/cookiesManager/UniversalCookiesManager.ts
similarity index 78%
rename from src/utils/UniversalCookiesManager.ts
rename to src/modules/core/cookiesManager/UniversalCookiesManager.ts
index 1144c2a4f..4836bfa69 100644
--- a/src/utils/UniversalCookiesManager.ts
+++ b/src/modules/core/cookiesManager/UniversalCookiesManager.ts
@@ -1,16 +1,24 @@
import * as Sentry from '@sentry/node';
import { COOKIE_LOOKUP_KEY_LANG } from '@unly/universal-language-detector/lib';
import { isBrowser } from '@unly/utils';
-import ServerCookies, { GetOption, SetOption } from 'cookies';
-import { IncomingMessage, ServerResponse } from 'http';
+import ServerCookies, {
+ GetOption,
+ SetOption,
+} from 'cookies';
+import {
+ IncomingMessage,
+ ServerResponse,
+} from 'http';
import BrowserCookies, { CookieAttributes } from 'js-cookie';
-import uuid from 'uuid/v1'; // XXX Use v1 for uniqueness - See https://www.sohamkamani.com/blog/2016/10/05/uuid1-vs-uuid4/
+import { v1 as uuid } from 'uuid'; // XXX Use v1 for uniqueness - See https://www.sohamkamani.com/blog/2016/10/05/uuid1-vs-uuid4/
+import { addYears } from '../date/date';
+import {
+ PatchedUserSemiPersistentSession,
+ UserSemiPersistentSession,
+} from '../userSession/types/UserSemiPersistentSession';
+import { Cookies } from './types/Cookies';
-import { Cookies } from '../types/Cookies';
-import { PatchedUserSemiPersistentSession, UserSemiPersistentSession } from '../types/UserSemiPersistentSession';
-import { addYears } from './date';
-
-const USER_LS_KEY = 'user';
+export const USER_LS_KEY = 'user';
/**
* Helper to manage cookies universally whether being on the server or browser
@@ -54,24 +62,28 @@ export default class UniversalCookiesManager {
* @param browserOptions
*/
replaceUserData(newUserData: UserSemiPersistentSession, serverOptions = this.defaultServerOptions, browserOptions: CookieAttributes = this.defaultBrowserOptions): void {
- if (isBrowser()) {
- // XXX By default, "js-cookies" apply a "percent encoding" when writing data, which isn't compatible with the "cookies" lib
- // We therefore override this behaviour because we need to write proper JSON
- // See https://github.com/js-cookie/js-cookie#encoding
- const browserCookies = BrowserCookies.withConverter({
- write: function (value: string, name: string) {
- return value;
- },
- });
- browserCookies.set(USER_LS_KEY, JSON.stringify(newUserData), browserOptions);
- } else {
- const serverCookies = new ServerCookies(this.req, this.res);
+ try {
+ if (isBrowser()) {
+ // XXX By default, "js-cookies" apply a "percent encoding" when writing data, which isn't compatible with the "cookies" lib
+ // We therefore override this behaviour because we need to write proper JSON
+ // See https://github.com/js-cookie/js-cookie#encoding
+ const browserCookies = BrowserCookies.withConverter({
+ write: function (value: string, name: string) {
+ return value;
+ },
+ });
+ browserCookies.set(USER_LS_KEY, JSON.stringify(newUserData), browserOptions);
+ } else {
+ const serverCookies = new ServerCookies(this.req, this.res);
- // If running on the server side but req or res aren't set, then we don't do anything
- // It's likely because we're calling this code from a view (that doesn't belong to getInitialProps and doesn't have access to req/res even though if it's running on the server)
- if (this.req && this.res) {
- serverCookies.set(USER_LS_KEY, JSON.stringify(newUserData), serverOptions);
+ // If running on the server side but req or res aren't set, then we don't do anything
+ // It's likely because we're calling this code from a view (that doesn't belong to getInitialProps and doesn't have access to req/res even though if it's running on the server)
+ if (this.req && this.res) {
+ serverCookies.set(USER_LS_KEY, JSON.stringify(newUserData), serverOptions);
+ }
}
+ } catch (e) {
+ Sentry.captureException(e);
}
}
diff --git a/src/utils/cookies.ts b/src/modules/core/cookiesManager/cookies.ts
similarity index 74%
rename from src/utils/cookies.ts
rename to src/modules/core/cookiesManager/cookies.ts
index 8ccd9beff..c802c2fae 100644
--- a/src/utils/cookies.ts
+++ b/src/modules/core/cookiesManager/cookies.ts
@@ -1,10 +1,14 @@
+import size from 'lodash.size';
+
/**
+ * Delete all existing cookies in the browser
+ *
* @see https://stackoverflow.com/a/179514/2391795
*/
export const deleteAllCookies = (): void => {
const cookies = document.cookie.split(';');
- for (let i = 0; i < cookies.length; i++) {
+ for (let i = 0; i < size(cookies); i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf('=');
const name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie;
diff --git a/src/types/Cookies.ts b/src/modules/core/cookiesManager/types/Cookies.ts
similarity index 100%
rename from src/types/Cookies.ts
rename to src/modules/core/cookiesManager/types/Cookies.ts
diff --git a/src/utils/css.test.ts b/src/modules/core/css/css.test.ts
similarity index 79%
rename from src/utils/css.test.ts
rename to src/modules/core/css/css.test.ts
index b629eb20d..bf996e759 100644
--- a/src/utils/css.test.ts
+++ b/src/modules/core/css/css.test.ts
@@ -1,14 +1,24 @@
import { cssToReactStyle } from './css';
+/**
+ * @group unit
+ * @group utils
+ */
describe(`utils/css.ts`, () => {
describe(`cssToReactStyle`, () => {
describe(`should convert to react style object correctly`, () => {
test(`when using multiple rules but doesn't end with a ";"`, async () => {
- expect(cssToReactStyle(`border-width: 5px; padding: 8px 10px`)).toEqual({ borderWidth: `5px`, padding: `8px 10px` });
+ expect(cssToReactStyle(`border-width: 5px; padding: 8px 10px`)).toEqual({
+ borderWidth: `5px`,
+ padding: `8px 10px`,
+ });
});
test(`when using multiple rules that end with a ";"`, async () => {
- expect(cssToReactStyle(`border-width: 5px; padding: 8px 10px;`)).toEqual({ borderWidth: `5px`, padding: `8px 10px` });
+ expect(cssToReactStyle(`border-width: 5px; padding: 8px 10px;`)).toEqual({
+ borderWidth: `5px`,
+ padding: `8px 10px`,
+ });
});
test(`when using one rule that ends with a ";"`, async () => {
diff --git a/src/utils/css.ts b/src/modules/core/css/css.ts
similarity index 86%
rename from src/utils/css.ts
rename to src/modules/core/css/css.ts
index 4aec7b3ca..eac606e5d 100644
--- a/src/utils/css.ts
+++ b/src/modules/core/css/css.ts
@@ -1,11 +1,13 @@
+import { createLogger } from '@/modules/core/logging/logger';
import * as Sentry from '@sentry/node';
-import { createLogger } from '@unly/utils-simple-logger';
import { getPropertyName } from 'css-to-react-native';
import isPlainObject from 'lodash.isplainobject';
import map from 'lodash.map';
+import { CSSStyles } from './types/CSSStyles';
+const fileLabel = 'modules/core/css/css';
const logger = createLogger({
- label: 'utils/css',
+ fileLabel,
});
/**
@@ -22,11 +24,10 @@ const logger = createLogger({
* @param css
* @return {object}
*/
-export const cssToReactStyle = (css: string | object): object => {
+export const cssToReactStyle = (css: string | CSSStyles): CSSStyles => {
// If object is given, return object (could be react style object mistakenly provided)
if (isPlainObject(css)) {
- // @ts-ignore
- return css;
+ return css as CSSStyles;
}
// If falsy, then probably empty string or null, nothing to be done there
diff --git a/src/modules/core/css/types/CSSStyles.ts b/src/modules/core/css/types/CSSStyles.ts
new file mode 100644
index 000000000..634e76e13
--- /dev/null
+++ b/src/modules/core/css/types/CSSStyles.ts
@@ -0,0 +1,6 @@
+import { GenericObject } from '@/modules/core/data/types/GenericObject';
+
+/**
+ * Represents a CSS "styles" object.
+ */
+export type CSSStyles = GenericObject;
diff --git a/src/modules/core/data/contexts/customerContext.tsx b/src/modules/core/data/contexts/customerContext.tsx
new file mode 100644
index 000000000..e659ef647
--- /dev/null
+++ b/src/modules/core/data/contexts/customerContext.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { Customer } from '../types/Customer';
+
+export type CustomerContext = Customer;
+
+/**
+ * Uses native React Context API
+ *
+ * @example Usage
+ * import customerContext from './src/stores/customerContext';
+ * const { locale, lang }: CustomerContext = React.useContext(customerContext);
+ *
+ * @see https://reactjs.org/docs/context.html
+ * @see https://medium.com/better-programming/react-hooks-usecontext-30eb560999f for useContext hook example (open in anonymous browser #paywall)
+ */
+export const customerContext = React.createContext(null);
+
+export default customerContext;
diff --git a/src/modules/core/data/contexts/datasetContext.tsx b/src/modules/core/data/contexts/datasetContext.tsx
new file mode 100644
index 000000000..b376699a6
--- /dev/null
+++ b/src/modules/core/data/contexts/datasetContext.tsx
@@ -0,0 +1,18 @@
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+import React from 'react';
+
+export type DatasetContext = GraphCMSDataset;
+
+/**
+ * Uses native React Context API
+ *
+ * @example Usage
+ * import datasetContext from './src/stores/datasetContext';
+ * const dataset: DatasetContext = React.useContext(datasetContext);
+ *
+ * @see https://reactjs.org/docs/context.html
+ * @see https://medium.com/better-programming/react-hooks-usecontext-30eb560999f for useContext hook example (open in anonymous browser #paywall)
+ */
+export const datasetContext = React.createContext(null);
+
+export default datasetContext;
diff --git a/src/modules/core/data/hooks/useCustomer.tsx b/src/modules/core/data/hooks/useCustomer.tsx
new file mode 100644
index 000000000..73b81ffde
--- /dev/null
+++ b/src/modules/core/data/hooks/useCustomer.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import customerContext from '../contexts/customerContext';
+import { Customer } from '../types/Customer';
+
+/**
+ * Hook to access customer data
+ *
+ * The customer data are pre-fetched, either during SSR or SSG and are not meant to be mutated afterwards (they're kinda read-only)
+ * Thus, it's fine to use React Context for this kind of usage.
+ *
+ * XXX If you need to store data that are meant to be updated (e.g: through forms) then using React Context is a very bad idea!
+ * If you don't know why, you should read more about it.
+ * Long story short, React Context is better be used with data that doesn't mutate, like theme/localisation
+ * Read more at https://medium.com/swlh/recoil-another-react-state-management-library-97fc979a8d2b
+ * If you need to handle a global state that changes over time, your should rather use a dedicated library (opinionated: I'd probably use Recoil)
+ *
+ * Uses customerContext internally (provides an identical API)
+ *
+ * This hook should be used by components in favor of customerContext directly,
+ * because it grants higher flexibility if you ever need to change the implementation (e.g: use something else than React.Context, like Redux/MobX/Recoil)
+ *
+ * @see https://slides.com/djanoskova/react-context-api-create-a-reusable-snackbar#/11
+ */
+const useCustomer = (): Customer => {
+ return React.useContext(customerContext);
+};
+
+export default useCustomer;
diff --git a/src/modules/core/data/hooks/useDataset.tsx b/src/modules/core/data/hooks/useDataset.tsx
new file mode 100644
index 000000000..6e9f4b6a4
--- /dev/null
+++ b/src/modules/core/data/hooks/useDataset.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import datasetContext, { DatasetContext } from '../contexts/datasetContext';
+
+/**
+ * Hook to access dataset data
+ *
+ * The dataset data are pre-fetched, either during SSR or SSG and are not meant to be mutated afterwards (they're kinda read-only)
+ * Thus, it's fine to use React Context for this kind of usage.
+ *
+ * XXX If you need to store data that are meant to be updated (e.g: through forms) then using React Context is a very bad idea!
+ * If you don't know why, you should read more about it.
+ * Long story short, React Context is better be used with data that doesn't mutate, like theme/localisation
+ * Read more at https://medium.com/swlh/recoil-another-react-state-management-library-97fc979a8d2b
+ * If you need to handle a global state that changes over time, your should rather use a dedicated library (opinionated: I'd probably use Recoil)
+ *
+ * Uses datasetContext internally (provides an identical API)
+ *
+ * This hook should be used by components in favor of datasetContext directly,
+ * because it grants higher flexibility if you ever need to change the implementation (e.g: use something else than React.Context, like Redux/MobX/Recoil)
+ *
+ * @see https://slides.com/djanoskova/react-context-api-create-a-reusable-snackbar#/11
+ */
+const useDataset = (): DatasetContext => {
+ return React.useContext(datasetContext);
+};
+
+export default useDataset;
diff --git a/src/utils/record.test.ts b/src/modules/core/data/record.test.ts
similarity index 59%
rename from src/utils/record.test.ts
rename to src/modules/core/data/record.test.ts
index 42d91195e..4f7c87026 100644
--- a/src/utils/record.test.ts
+++ b/src/modules/core/data/record.test.ts
@@ -1,26 +1,31 @@
import {
- FallbackConfig, FallbackConfigTransformProps, filterSelectedRecords, getValue, getValueFallback, hasValue, NOT_FOUND, Record, STRATEGY_DO_NOTHING,
+ FallbackConfig,
+ FallbackConfigTransformProps,
+ filterSelectedRecords,
+ GenericRecord,
+ getValueFallback,
+ hasValue,
+ NOT_FOUND,
} from './record';
-describe('utils/record.ts', () => {
- beforeEach(() => {
- // Silent console log (used by logger.warn)
- // @ts-ignore
- global.console = { warn: jest.fn(), log: jest.fn() };
- });
-
+/**
+ * @group unit
+ * @group utils
+ */
+describe('utils/data/record.ts', () => {
describe('hasValue', () => {
test('should return false when the given value is not defined', async () => {
- const item: Record = {
+ const item: GenericRecord = {
'': '',
emptyObject: {},
emptyArray: [],
null: null,
notFound: NOT_FOUND,
htmlEmptyParagraph: '',
+ airtableEmptyLongTextField: '\n',
};
- // @ts-ignore
+ // @ts-expect-error
expect(hasValue(item)).toEqual(false);
expect(hasValue(item, null)).toEqual(false);
expect(hasValue(item, undefined)).toEqual(false);
@@ -30,11 +35,12 @@ describe('utils/record.ts', () => {
expect(hasValue(item, 'null')).toEqual(false);
expect(hasValue(item, 'notFound')).toEqual(false);
expect(hasValue(item, 'htmlEmptyParagraph')).toEqual(false);
+ expect(hasValue(item, 'airtableEmptyLongTextField')).toEqual(false);
});
// Edge cases, not handled yet
test('should return true when the given value is defined', async () => {
- const item: Record = {
+ const item: GenericRecord = {
string: 'string',
string2: '0',
string3: '-1',
@@ -60,105 +66,6 @@ describe('utils/record.ts', () => {
});
});
- describe('getValue', () => {
- test('should return null when the given key is not defined without logging the error (with STRATEGY_DO_NOTHING)', async () => {
- const item: Record = {
- false: false,
- true: true,
- empty: '',
- 0: 0,
- };
-
- expect(getValue(item, 'notThere', null, STRATEGY_DO_NOTHING)).toEqual(null);
- expect(getValue(item, 'notThereEither', null, STRATEGY_DO_NOTHING)).toEqual(null);
- expect(getValue(item, '1', null, STRATEGY_DO_NOTHING)).toEqual(null);
- expect(getValue(item, '_', null, STRATEGY_DO_NOTHING)).toEqual(null);
- expect(getValue(item, '', null, STRATEGY_DO_NOTHING)).toEqual(null);
- expect(getValue(item, null, null, STRATEGY_DO_NOTHING)).toEqual(null);
- // @ts-ignore
- expect(getValue(item, 50.5, null, STRATEGY_DO_NOTHING)).toEqual(null);
- // @ts-ignore
- expect(getValue(item, {}, null, STRATEGY_DO_NOTHING)).toEqual(null);
- // @ts-ignore
- expect(getValue(item, { 0: 0 }, null, STRATEGY_DO_NOTHING)).toEqual(null);
- // @ts-ignore
- expect(getValue(item, 0, null, STRATEGY_DO_NOTHING)).toEqual(null);
- expect(console.log).not.toBeCalled();
- });
-
- test('should return null when the given key is not defined and log the error (without STRATEGY_DO_NOTHING)', async () => {
- const item: Record = {
- false: false,
- true: true,
- empty: '',
- 0: 0,
- };
-
- expect(getValue(item, 'notThere')).toEqual(null);
- expect(console.log).toBeCalled();
- expect(getValue(item, 'notThereEither')).toEqual(null);
- expect(console.log).toBeCalled();
- expect(getValue(item, '1')).toEqual(null);
- expect(console.log).toBeCalled();
- expect(getValue(item, '_')).toEqual(null);
- expect(console.log).toBeCalled();
- expect(getValue(item, '')).toEqual(null);
- expect(console.log).toBeCalled();
- expect(getValue(item, null)).toEqual(null);
- expect(console.log).toBeCalled();
- // @ts-ignore
- expect(getValue(item, 50.5)).toEqual(null);
- expect(console.log).toBeCalled();
- // @ts-ignore
- expect(getValue(item, {})).toEqual(null);
- expect(console.log).toBeCalled();
- // @ts-ignore
- expect(getValue(item, { 0: 0 })).toEqual(null);
- expect(console.log).toBeCalled();
- // @ts-ignore
- expect(getValue(item, 0)).toEqual(null);
- expect(console.log).toBeCalled();
- });
-
- test('should return the expected value when the given value is defined (with multi depths)', async () => {
- const item: Record = {
- // @ts-ignore
- string: 'string',
- string2: '0',
- string3: '-1',
- object: { a: 0 },
- object2: { 0: 0 },
- object3: { '': 0 },
- array: [0],
- array1: [1],
- array2: [-1],
- htmlParagraph: '
+
+
+ The below "debug info" are only displayed on non-production stages.
+ Note that debug information about the error are also available on the server/browser console.
+
+
+
+
+
+ );
+};
+
+export default ErrorDebug;
diff --git a/src/modules/core/fontAwesome/fontAwesome.ts b/src/modules/core/fontAwesome/fontAwesome.ts
new file mode 100644
index 000000000..25a07a292
--- /dev/null
+++ b/src/modules/core/fontAwesome/fontAwesome.ts
@@ -0,0 +1,66 @@
+import {
+ config,
+ library,
+} from '@fortawesome/fontawesome-svg-core';
+import '@fortawesome/fontawesome-svg-core/styles.css';
+import { faGithub } from '@fortawesome/free-brands-svg-icons';
+import { faTimesCircle } from '@fortawesome/free-regular-svg-icons';
+import {
+ faArrowCircleLeft,
+ faArrowCircleRight,
+ faArrowRight,
+ faBook,
+ faBookReader,
+ faCoffee,
+ faExclamationTriangle,
+ faFileAlt,
+ faHome,
+ faLink,
+ faQuestionCircle,
+ faSync,
+ faUserCog,
+} from '@fortawesome/free-solid-svg-icons';
+
+// See https://github.com/FortAwesome/react-fontawesome#integrating-with-other-tools-and-frameworks
+config.autoAddCss = false; // Tell Font Awesome to skip adding the CSS automatically since it's being imported above
+
+/**
+ * Configure the Font-Awesome icons library by pre-registering icon definitions so that we do not have to explicitly pass them to render an icon.
+ * Necessary for proper server-side rendering of icons.
+ *
+ * XXX Since Next.js 10, it is possible to import CSS file outside of the _app.tsx file.
+ * We leverage this new feature to configure our Font-Awesome icons outside of _app to avoid cluttering that file.
+ *
+ * @example
+ *
+ * @see https://fontawesome.com/how-to-use/javascript-api/methods/library-add
+ * @see https://nextjs.org/blog/next-10#importing-css-from-third-party-react-components
+ */
+
+// Import @fortawesome/free-brands-svg-icons
+library.add(
+ faGithub,
+);
+
+// Import @fortawesome/free-regular-svg-icons
+library.add(
+ faTimesCircle,
+);
+
+// Import @fortawesome/free-solid-svg-icons
+library.add(
+ faArrowCircleLeft,
+ faArrowCircleRight,
+ faArrowRight,
+ faBook,
+ faBookReader,
+ faCoffee,
+ faExclamationTriangle,
+ faFileAlt,
+ faHome,
+ faLink,
+ faQuestionCircle,
+ faSync,
+ faUserCog,
+)
+;
diff --git a/src/modules/core/fonts/fonts.ts b/src/modules/core/fonts/fonts.ts
new file mode 100644
index 000000000..42e475c56
--- /dev/null
+++ b/src/modules/core/fonts/fonts.ts
@@ -0,0 +1,126 @@
+/**
+ * List of allowed "Variable Fonts" that can be used in the project.
+ *
+ * By default, NRN comes with "Manrope" and "Inter" custom fonts built-in.
+ * Both are open-source.
+ *
+ * The "active font" can be defined by each customer independently, as long as they are configured as "allowed fonts" below.
+ *
+ * @see https://fonts.google.com/specimen/Manrope
+ * @see https://fonts.google.com/specimen/Inter
+ */
+export type AllowedVariableFont = 'Manrope' | 'Inter';
+
+export type VariableFontConfig = {
+ fontName?: AllowedVariableFont;
+ format?: 'woff' | 'woff2'; // Defaults to woff2
+ fontFile?: string; // Defaults to "${fontName}-variable-latin.${format}"
+};
+
+export const variableFontsConfig: VariableFontConfig[] = [
+ { fontName: 'Manrope' },
+ { fontName: 'Inter' },
+];
+
+export const fontsBasePath = '/static/fonts';
+
+/**
+ * Injects the font-faces of a font family (a family can have multiple font-faces).
+ *
+ * Meant to be used from MultiversalGlobalStyles, to inject fonts in the global CSS, so they can be used anywhere in the app.
+ * The fonts are self-hosted in "/public/static/fonts" folder.
+ *
+ * Alternatively to self-hosted fonts, you can also use Google Fonts directly, which are simpler to use since Next.js v10.2.
+ * See https://nextjs.org/blog/next-10-2#automatic-webfont-optimization.
+ *
+ * Key concepts:
+ * - Only use WOFF/WOFF2 format, never use TTF or OTF (they're not meant for the web!)
+ * - Only use "font-display: optional" (using "block" (or none) will FOIT, using "swap" will FOUT/FOFT)
+ *
+ * NRN fonts use recommended best-practices from Lee Robinson (Vercel):
+ * - Self hosted (on Vercel).
+ * It's better to self-host than use Google Fonts, even though Next.js has advanced optimisations for Google Fonts,
+ * because self-hosted fonts are more reliable and don't require a round-trip to another server.
+ * Also, Google Fonts is blocked in some countries, like China. Self-hosting is the only alternative in such case.
+ * - Use "Variable Font".
+ * Only one file for all font-faces. (instead of one for light, bold, medium, extraBold, etc.)
+ * Reduces the amount of kB the browser needs to fetch, and reduces the network overhead.
+ * - Use Variable Font "subset".
+ * Only fetch part of the font, not all the glyphs.
+ * We only need "latin" subset. This is configured by "unicode-range".
+ * If you want to fetch the whole thing, simply adapt or remove "unicode-range".
+ * - Use "Preloading".
+ * Use "rel=preload" in the `` element, to start preloading the font file at the very beginning.
+ * - Use "font-display: optional" to avoid FOUT/FOIT effects.
+ * - The font won't be used if it takes too much time to fetch. Basically, it won't be used the first time a user will load the site.
+ * - This is a caveat, and if you don't like it you can use "font-display: swap" instead, but then your users will suffer from FOUT effect the first time they load the site.
+ * - Also, FOUT might (very likely) negatively affect Google Cumulative Layout Shift (CLS) performance score, see https://web.dev/cls/
+ * - Alternatively, you can use "font-display: swap" AND select a fallback font that looks very similar to your font using https://meowni.ca/font-style-matcher/
+ * - Browser caching.
+ * - All fonts are automatically cached by the browser for 1 year. See next.config.js:headers.
+ *
+ * Considerations for the future:
+ * - Check out "Font Metrics Override" (experimental) https://leerob.io/blog/fonts#future
+ * Video: https://www.youtube.com/watch?v=Z6wjUOSh9Tk&t=176s
+ * Basically, something that's only possible with Chrome (as of May 2021) but should become usable in more browsers in the future.
+ * This will make it much easier to use a custom font with "font-display: swap" and configure the fallback font to display exactly the same (same height, width, etc.)
+ * So that when the "swap" happens, it doesn't affect the CLS score.
+ *
+ * Workflow to configure a new font (we're using Google Fonts even when self-hosting because it eases the process a lot):
+ * 1) Find your font on Google Font (or have it ready).
+ * E.g: https://fonts.google.com/specimen/Manrope
+ * 2) Select the font and copy its "" url.
+ * E.g: https://fonts.googleapis.com/css2?family=Manrope&display=swap
+ * 3) Look for the min/max weight of your font at the specification page.
+ * E.g: https://fonts.google.com/variablefonts?vfquery=manrope indicates min/max weight to be 200-800
+ * 4) Change the url parameters manually, use "optional" and use a variable font with "weight" as a range.
+ * E.g: https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=optional
+ * 5) Go to the url (https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=optional in our case)
+ * 6) Copy the font-faces you need and add them below, as a new allow font.
+ * E.g: We only need "latin"
+ * 7) Download the font-faces.
+ * E.g: https://fonts.gstatic.com/s/manrope/v4/xn7gYHE41ni1AdIRggexSvfedN4.woff2
+ * 8) Copy the downloaded font-faces files (.woff2) to their folder in "/public/static/fonts" and replace the font-faces "src" by their new path.
+ * E.g: src: url(${basePath}/Manrope/Manrope-variable-latin.woff2) format('woff2');
+ * 9) (Optional) Update NRN_DEFAULT_FONT constant to use your new font by default, if you wish to.
+ *
+ * @param fontName
+ *
+ * @see https://css-tricks.com/fout-foit-foft/ Acronyms explanations
+ * @see https://developers.google.com/fonts/docs/css2#axis_ranges Learn how to configure Google Fonts axis ranges
+ * @see https://leerob.io/blog/fonts Inspiration from Vercel
+ * @see https://www.youtube.com/watch?v=G0cOQ79WKZE Comprehensive video about how to properly configure Fonts in 2021
+ * @see https://csswizardry.com/2020/05/the-fastest-google-fonts/ In-depth understanding of the different configurations and how they affect perfs
+ * @see https://stackoverflow.com/questions/11002820/why-should-we-include-ttf-eot-woff-svg-in-a-font-face
+ * @see https://stackoverflow.com/questions/36105194/are-eot-ttf-and-svg-still-necessary-in-the-font-face-declaration/36110385#36110385
+ */
+export const injectFontFamily = (fontName: AllowedVariableFont): string => {
+
+ switch (fontName) {
+ // From https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=optional
+ case 'Manrope':
+ return `
+ @font-face {
+ font-family: 'Manrope';
+ font-style: normal;
+ font-weight: 200 800;
+ font-display: optional;
+ src: url(${fontsBasePath}/Manrope/Manrope-variable-latin.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+ }
+ `;
+
+ // From https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=optional
+ case 'Inter':
+ return `
+ @font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ font-weight: 100 900;
+ font-display: optional;
+ src: url(${fontsBasePath}/Inter/Inter-variable-latin.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+ }
+ `;
+ }
+};
diff --git a/src/modules/core/githubActions/dispatchWorkflow.ts b/src/modules/core/githubActions/dispatchWorkflow.ts
new file mode 100644
index 000000000..bc872ad09
--- /dev/null
+++ b/src/modules/core/githubActions/dispatchWorkflow.ts
@@ -0,0 +1,120 @@
+import { createLogger } from '@/modules/core/logging/logger';
+import { ALERT_TYPES } from '@/modules/core/sentry/config';
+import * as Sentry from '@sentry/node';
+import { WorkflowsAPIResponse } from './types/WorkflowsAPIResponse';
+
+const fileLabel = 'modules/core/githubActions/dispatchWorkflow';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Dispatches a GitHub Actions workflow.
+ *
+ * Uses "workflowFilePath" to resolve which workflow to trigger.
+ *
+ * @param workflowsList
+ * @param platformReleaseRef
+ * @param workflowFilePath
+ */
+export const dispatchWorkflow = async (workflowsList: WorkflowsAPIResponse, platformReleaseRef: string, workflowFilePath: string): Promise => {
+ try {
+ const [workflowDetails] = workflowsList?.workflows?.filter((workflow) => workflow?.path === workflowFilePath);
+
+ if (workflowDetails) {
+ /**
+ * Creates a workflow dispatch event.
+ *
+ * @see https://docs.github.com/en/free-pro-team@latest/rest/reference/actions#create-a-workflow-dispatch-event
+ */
+ const url = `${workflowDetails?.url}/dispatches`;
+ const body = {
+ inputs: {
+ customer: process.env.NEXT_PUBLIC_CUSTOMER_REF,
+ },
+ ref: platformReleaseRef,
+ };
+ const options = {
+ method: 'POST',
+ headers: {
+ Accept: 'application/vnd.github.v3+json',
+ },
+ body: JSON.stringify(body),
+ };
+
+ if (process.env.GITHUB_DISPATCH_TOKEN) {
+ // Authorization token, required if the repository is private, unnecessary if the repo is public
+ options.headers['Authorization'] = `token ${process.env.GITHUB_DISPATCH_TOKEN}`;
+ }
+
+ Sentry.configureScope((scope): void => {
+ scope.setExtra('workflowFilePath', workflowFilePath);
+ scope.setExtra('workflowDispatchRequestUrl', url);
+ scope.setExtra('platformReleaseRef', platformReleaseRef);
+ scope.setContext('workflowDispatchRequestBody', body);
+ scope.setContext('workflowDetails', workflowDetails);
+ });
+
+ Sentry.withScope((scope): void => {
+ scope.setTag('alertType', ALERT_TYPES.VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT);
+
+ Sentry.captureEvent({
+ message: `Attempting to trigger a Vercel deployment using "${workflowFilePath}" with version "${platformReleaseRef}".`,
+ level: Sentry.Severity.Log,
+ });
+ });
+
+ logger.debug(`Fetching "${url}", using workflow path: "${workflowFilePath}", with request body: ${JSON.stringify(body, null, 2)}`);
+ const response = await fetch(url, options);
+
+ // If the response status isn't 2XX, then something wrong happened
+ if (!response?.status?.toString()?.startsWith('2')) {
+ let errorMessage;
+
+ try {
+ // Response might contain JSON or plain text, attempt to stringify JSON, will fail if no valid JSON found
+ const result = await response.json();
+ errorMessage = JSON.stringify(result, null, 2);
+
+ Sentry.captureException(new Error(errorMessage));
+ logger.error(errorMessage);
+ } catch (e) {
+ // Stringifying JSON failed, attempt to retrieve the plain text error message
+ Sentry.captureException(e);
+ logger.error(e);
+
+ errorMessage = await response.text();
+ Sentry.captureException(errorMessage);
+ logger.error(errorMessage);
+ } finally {
+ Sentry.withScope((scope): void => {
+ scope.setTag('alertType', ALERT_TYPES.VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT_FAILED);
+
+ Sentry.captureEvent({
+ message: `Failed to trigger a Vercel deployment using "${workflowFilePath}" with version "${platformReleaseRef}". Error: "${errorMessage}"`,
+ level: Sentry.Severity.Error,
+ });
+ });
+ }
+ } else {
+ Sentry.withScope((scope): void => {
+ scope.setTag('alertType', ALERT_TYPES.VERCEL_DEPLOYMENT_TRIGGER_ATTEMPT_SUCCEEDED);
+
+ Sentry.captureEvent({
+ message: `Successfully triggered a Vercel deployment using "${workflowFilePath}" with version "${platformReleaseRef}".`,
+ level: Sentry.Severity.Log,
+ });
+ });
+ }
+ } else {
+ const errorMessage = `No GitHub Actions workflow could be found for file path: "${workflowFilePath}"`;
+ Sentry.captureException(new Error(errorMessage));
+ logger.error(errorMessage);
+ }
+ } catch (e) {
+ Sentry.captureException(e);
+ logger.error(e);
+ }
+};
+
+export default dispatchWorkflow;
diff --git a/src/modules/core/githubActions/dispatchWorkflowByPath.ts b/src/modules/core/githubActions/dispatchWorkflowByPath.ts
new file mode 100644
index 000000000..ef31f68f7
--- /dev/null
+++ b/src/modules/core/githubActions/dispatchWorkflowByPath.ts
@@ -0,0 +1,77 @@
+import {
+ GITHUB_API_BASE_URL,
+ GITHUB_OWNER_NAME,
+ GITHUB_REPO_NAME,
+} from '@/app/constants';
+import { createLogger } from '@/modules/core/logging/logger';
+import * as Sentry from '@sentry/node';
+import dispatchWorkflow from './dispatchWorkflow';
+import { WorkflowsAPIResponse } from './types/WorkflowsAPIResponse';
+
+const fileLabel = 'modules/core/githubActions/dispatchWorkflowByPath';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Endpoint to list the workflows of a repository.
+ * Public if the repository is public.
+ *
+ * @see https://docs.github.com/en/free-pro-team@latest/rest/reference/actions#list-repository-workflows
+ */
+const GITHUB_API_LIST_PROJECT_WORKFLOWS = `${GITHUB_API_BASE_URL}/repos/${GITHUB_OWNER_NAME}/${GITHUB_REPO_NAME}/actions/workflows`;
+
+type GitHubAPIError = {
+ message?: string;
+ documentation_url: string;
+}
+
+/**
+ * Fetches all GitHub Actions workflows then dispatches the workflow referenced by "workflowFilePath".
+ *
+ * @param platformReleaseRef
+ * @param workflowFilePath
+ */
+export const dispatchWorkflowByPath = async (platformReleaseRef: string, workflowFilePath: string): Promise => {
+ try {
+ logger.debug(`Fetching "${GITHUB_API_LIST_PROJECT_WORKFLOWS}"`);
+
+ const options = {
+ method: 'GET',
+ headers: {
+ Accept: 'application/vnd.github.v3+json',
+ },
+ };
+
+ if (process.env.GITHUB_DISPATCH_TOKEN) {
+ // Authorization token, required if the repository is private, unnecessary if the repo is public
+ options.headers['Authorization'] = `token ${process.env.GITHUB_DISPATCH_TOKEN}`;
+ }
+
+ const response = await fetch(GITHUB_API_LIST_PROJECT_WORKFLOWS, options);
+ const results: WorkflowsAPIResponse | GitHubAPIError = await response.json();
+
+ if (response.status !== 200) {
+ // Something wrong happened
+ const error: GitHubAPIError = results as GitHubAPIError;
+ const message = error?.message + (error?.documentation_url ? ` - See ${error?.documentation_url}` : '');
+
+ logger.error(message);
+ Sentry.withScope((scope): void => {
+ scope.setExtra('response (raw)', response);
+ scope.setContext('results (parsed)', results);
+ Sentry.captureException(message);
+ });
+
+ return Promise.resolve();
+ } else {
+ await dispatchWorkflow(results as WorkflowsAPIResponse, platformReleaseRef, workflowFilePath);
+ }
+
+ } catch (e) {
+ Sentry.captureException(new Error(e));
+ logger.error(e);
+ }
+};
+
+export default dispatchWorkflowByPath;
diff --git a/src/modules/core/githubActions/types/GHAWorkflow.ts b/src/modules/core/githubActions/types/GHAWorkflow.ts
new file mode 100644
index 000000000..c90b5b1d2
--- /dev/null
+++ b/src/modules/core/githubActions/types/GHAWorkflow.ts
@@ -0,0 +1,19 @@
+/**
+ * GitHub Actions Workflow.
+ *
+ * Represents a single Workflow.
+ *
+ * @see https://developer.github.com/v3/actions/workflows/#response
+ */
+export type GHAWorkflow = {
+ id: number;
+ node_id: string;
+ name: string;
+ path: string;
+ state: 'active'; // "inactive"?
+ created_at: string;
+ updated_at: string;
+ url: string;
+ html_url: string;
+ badge_url: string;
+}
diff --git a/src/modules/core/githubActions/types/WorkflowsAPIResponse.ts b/src/modules/core/githubActions/types/WorkflowsAPIResponse.ts
new file mode 100644
index 000000000..7bb754c40
--- /dev/null
+++ b/src/modules/core/githubActions/types/WorkflowsAPIResponse.ts
@@ -0,0 +1,11 @@
+import { GHAWorkflow } from './GHAWorkflow';
+
+/**
+ * GitHub Actions Workflows API response.
+ *
+ * @see https://developer.github.com/v3/actions/workflows/#response
+ */
+export type WorkflowsAPIResponse = {
+ total_count: number;
+ workflows: GHAWorkflow[];
+}
diff --git a/src/components/GraphCMSAsset.test.tsx b/src/modules/core/gql/components/GraphCMSAsset.test.tsx
similarity index 92%
rename from src/components/GraphCMSAsset.test.tsx
rename to src/modules/core/gql/components/GraphCMSAsset.test.tsx
index 38cb2002d..74dbc5191 100644
--- a/src/components/GraphCMSAsset.test.tsx
+++ b/src/modules/core/gql/components/GraphCMSAsset.test.tsx
@@ -1,10 +1,14 @@
import React from 'react';
import TestRenderer from 'react-test-renderer';
+
import GraphCMSAsset from './GraphCMSAsset';
const defaultLogoUrl = 'https://media.graphcms.com/88YmsSFsSEGI9i0qcH0V';
-const defaultLogoTarget = '_blank';
+/**
+ * @group unit
+ * @group components
+ */
describe('GraphCMSAsset', () => {
describe('should properly render an asset from GraphCMS', () => {
describe('when the asset is used as an image ()', () => {
@@ -17,12 +21,12 @@ describe('GraphCMSAsset', () => {
url: defaultLogoUrl,
}}
/>);
- const img = renderer.toJSON();
+ const img: any = renderer.toJSON();
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
expect(img.props.title).toEqual('');
- expect(img.props.alt).toEqual('');
+ expect(img.props.alt).toEqual(defaultLogoUrl);
expect(img.props.className).toEqual(`asset-${id}`);
expect(img.props.style).toEqual({});
expect(img).toMatchSnapshot();
@@ -44,7 +48,7 @@ describe('GraphCMSAsset', () => {
style: style,
}}
/>);
- const img = renderer.toJSON();
+ const img: any = renderer.toJSON();
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
@@ -74,7 +78,7 @@ describe('GraphCMSAsset', () => {
style: style,
}}
/>);
- const img = renderer.toJSON();
+ const img: any = renderer.toJSON();
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
@@ -105,7 +109,7 @@ describe('GraphCMSAsset', () => {
style: style,
}}
/>);
- const img = renderer.toJSON();
+ const img: any = renderer.toJSON();
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
@@ -140,7 +144,7 @@ describe('GraphCMSAsset', () => {
style: style,
}}
/>);
- const img = renderer.toJSON();
+ const img: any = renderer.toJSON();
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
@@ -172,10 +176,10 @@ describe('GraphCMSAsset', () => {
},
}}
/>);
- const img = renderer.toJSON();
+ const img: any = renderer.toJSON();
expect(img.props.id).toEqual(id);
- expect(img.props.src).toEqual('https://media.graphcms.com/quality=value:100/output=format:png/resize=width:500,height:300/88YmsSFsSEGI9i0qcH0V');
+ expect(img.props.src).toEqual('https://media.graphcms.com/quality=value:100/resize=width:500,height:300/auto_image/88YmsSFsSEGI9i0qcH0V');
expect(img.props.title).toEqual(title);
expect(img.props.alt).toEqual(title);
expect(img.props.className).toEqual(`asset-${id} ${classes}`);
@@ -202,10 +206,10 @@ describe('GraphCMSAsset', () => {
},
}}
/>);
- const img = renderer.toJSON();
+ const img: any = renderer.toJSON();
expect(img.props.id).toEqual(id);
- expect(img.props.src).toEqual('https://media.graphcms.com/quality=value:100/output=format:png/resize=width:500/88YmsSFsSEGI9i0qcH0V');
+ expect(img.props.src).toEqual('https://media.graphcms.com/quality=value:100/resize=width:500/auto_image/88YmsSFsSEGI9i0qcH0V');
expect(img.props.title).toEqual(title);
expect(img.props.alt).toEqual(title);
expect(img.props.className).toEqual(`asset-${id} ${classes}`);
@@ -232,13 +236,13 @@ describe('GraphCMSAsset', () => {
},
}}
transformationsOverride={{
- height: 300
+ height: 300,
}}
/>);
- const img = renderer.toJSON();
+ const img: any = renderer.toJSON();
expect(img.props.id).toEqual(id);
- expect(img.props.src).toEqual('https://media.graphcms.com/quality=value:100/output=format:png/resize=height:300/88YmsSFsSEGI9i0qcH0V');
+ expect(img.props.src).toEqual('https://media.graphcms.com/quality=value:100/resize=height:300/auto_image/88YmsSFsSEGI9i0qcH0V');
expect(img.props.title).toEqual(title);
expect(img.props.alt).toEqual(title);
expect(img.props.className).toEqual(`asset-${id} ${classes}`);
@@ -260,13 +264,13 @@ describe('GraphCMSAsset', () => {
linkUrl: linkUrl,
}}
/>);
- const link = renderer.toJSON();
- const img = renderer.root.findByType('img');
+ const link: any = renderer.toJSON();
+ const img: any = renderer.root.findByType('img');
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
expect(img.props.title).toEqual('');
- expect(img.props.alt).toEqual('');
+ expect(img.props.alt).toEqual(defaultLogoUrl);
expect(img.props.className).toEqual(`asset-${id}`);
expect(img.props.style).toEqual({});
expect(img.props).toMatchSnapshot();
@@ -302,8 +306,8 @@ describe('GraphCMSAsset', () => {
style: style,
}}
/>);
- const link = renderer.toJSON();
- const img = renderer.root.findByType('img');
+ const link: any = renderer.toJSON();
+ const img: any = renderer.root.findByType('img');
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
@@ -348,8 +352,8 @@ describe('GraphCMSAsset', () => {
style: style,
}}
/>);
- const link = renderer.toJSON();
- const img = renderer.root.findByType('img');
+ const link: any = renderer.toJSON();
+ const img: any = renderer.root.findByType('img');
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
@@ -396,8 +400,8 @@ describe('GraphCMSAsset', () => {
}}
onClick={onClick}
/>);
- const link = renderer.toJSON();
- const img = renderer.root.findByType('img');
+ const link: any = renderer.toJSON();
+ const img: any = renderer.root.findByType('img');
expect(img.props.id).toEqual(id);
expect(img.props.src).toEqual(defaultLogoUrl);
diff --git a/src/components/GraphCMSAsset.tsx b/src/modules/core/gql/components/GraphCMSAsset.tsx
similarity index 73%
rename from src/components/GraphCMSAsset.tsx
rename to src/modules/core/gql/components/GraphCMSAsset.tsx
index 9ee02630c..65b4d04ab 100644
--- a/src/components/GraphCMSAsset.tsx
+++ b/src/modules/core/gql/components/GraphCMSAsset.tsx
@@ -1,18 +1,88 @@
+import { cssToReactStyle } from '@/modules/core/css/css';
+import { CSSStyles } from '@/modules/core/css/types/CSSStyles';
+import { Asset } from '@/modules/core/data/types/Asset';
+import { AssetTransformations } from '@/modules/core/data/types/AssetTransformations';
import classnames from 'classnames';
import deepmerge from 'deepmerge';
import get from 'lodash.get';
import isEmpty from 'lodash.isempty';
import map from 'lodash.map';
-import PropTypes from 'prop-types';
import React from 'react';
-import stylePropType from 'react-style-proptype';
+import { Link } from '../../data/types/Link';
-import GraphCMSAssetPropTypes from '../propTypes/GraphCMSAssetPropTypes';
-import GraphCMSAssetTransformationsPropTypes from '../propTypes/GraphCMSAssetTransformationsPropTypes';
-import { Asset } from '../types/data/Asset';
-import { AssetTransformations } from '../types/data/AssetTransformations';
-import { Link } from '../types/data/Link';
-import { cssToReactStyle } from '../utils/css';
+type Props = {
+ /**
+ * Asset, extends GraphCMS asset, e.g: image, document, etc.
+ *
+ * XXX The `asset` will be merged with `_defaultAsset`.
+ */
+ asset: Asset;
+
+ /**
+ * HTML id attribute. Must be unique.
+ */
+ id: string;
+
+ /**
+ * Overrides transformations.
+ *
+ * @default null
+ */
+ transformationsOverride?: AssetTransformations;
+
+ /**
+ * Default `asset` properties.
+ *
+ * @default {}
+ */
+ defaults?: Asset;
+
+ /**
+ * Overrides `asset` properties.
+ *
+ * @default {}
+ */
+ override?: Asset;
+
+ /**
+ * CSS classes.
+ */
+ className?: string;
+
+ /**
+ * CSS styles
+ *
+ * @default null
+ */
+ style?: CSSStyles;
+
+ /**
+ * Click event.
+ */
+ onClick?: () => void;
+
+ /**
+ * Overrides `Link` element properties.
+ *
+ * @default {}
+ */
+ linkOverride?: {
+ id?: string;
+ url?: string;
+ target?: string;
+ style?: CSSStyles;
+ classes?: string;
+ };
+
+ /**
+ * Whether to force output format to be PNG, allows to display PDF files as images.
+ *
+ * It bypasses the usage of SVG files.
+ *
+ * @default false
+ */
+ forcePNGOutput?: boolean;
+}
const _defaultAsset = {
id: null,
@@ -38,11 +108,10 @@ const _defaultLink: Link = {
*
* @param props
* @return {null|*}
- * @constructor
*
* @see Transformations https://docs.graphcms.com/developers/assets/transformations/transforming-url-structure
*/
-const GraphCMSAsset = (props: Props): JSX.Element => {
+const GraphCMSAsset: React.FunctionComponent = (props): JSX.Element => {
const {
id,
asset,
@@ -53,7 +122,7 @@ const GraphCMSAsset = (props: Props): JSX.Element => {
onClick = null,
linkOverride = {},
transformationsOverride = null,
- forcePNGOutput = true,
+ forcePNGOutput = false,
}: Props = props;
if (isEmpty(asset)) {
return null;
@@ -109,18 +178,21 @@ const GraphCMSAsset = (props: Props): JSX.Element => {
}
// Once all transformations have been resolved, update the asset url
- resolvedAssetProps.url = `${assetBaseUrl}${transformationsToApply}/${assetFileHandle}`;
+ // XXX Using "auto_image" will automatically select the image type (WebP, etc.) based on the browser (reduces size by 20-80%, compared to png)
+ // See https://www.filestack.com/docs/api/processing/#auto-image-conversion
+ resolvedAssetProps.url = `${assetBaseUrl}${transformationsToApply}/auto_image/${assetFileHandle}`;
}
}
const Image = (): JSX.Element => {
return (
+ // eslint-disable-next-line @next/next/no-img-element
@@ -137,7 +209,6 @@ const GraphCMSAsset = (props: Props): JSX.Element => {
target={resolvedLinkProps.target}
className={classnames(`asset-link-${id}`, resolvedLinkProps.classes, resolvedLinkProps.className)}
style={deepmerge(resolvedLinkProps.style || {}, resolvedLinkProps.style || {})}
- // @ts-ignore
onClick={onClick} // Support for usage within component (from Next.js)
>
@@ -150,42 +221,4 @@ const GraphCMSAsset = (props: Props): JSX.Element => {
}
};
-GraphCMSAsset.propTypes = {
- id: PropTypes.string.isRequired,
- asset: PropTypes.shape(GraphCMSAssetPropTypes).isRequired,
- transformationsOverride: PropTypes.shape(GraphCMSAssetTransformationsPropTypes),
- defaults: PropTypes.shape(GraphCMSAssetPropTypes), // Merged with the asset, takes lowest priority
- override: PropTypes.shape(GraphCMSAssetPropTypes), // Merged with the asset, takes highest priority
- className: PropTypes.string,
- style: stylePropType,
- onClick: PropTypes.func, // Support for usage within component (from Next.js)
- linkOverride: PropTypes.shape({ // Merged with the default link and the asset link attributes, takes highest priority
- id: PropTypes.string.isRequired,
- url: PropTypes.string,
- target: PropTypes.string,
- style: PropTypes.object,
- classes: PropTypes.string,
- }),
- forceOutputPNG: PropTypes.bool,
-};
-
-type Props = {
- id: string;
- asset: Asset;
- transformationsOverride?: AssetTransformations;
- defaults?: Asset;
- override?: Asset;
- className?: string;
- style?: object;
- onClick?: Function;
- linkOverride?: {
- id?: string;
- url?: string;
- target?: string;
- style?: object;
- classes?: string;
- };
- forcePNGOutput?: boolean;
-}
-
export default GraphCMSAsset;
diff --git a/src/components/__snapshots__/GraphCMSAsset.test.tsx.snap b/src/modules/core/gql/components/__snapshots__/GraphCMSAsset.test.tsx.snap
similarity index 93%
rename from src/components/__snapshots__/GraphCMSAsset.test.tsx.snap
rename to src/modules/core/gql/components/__snapshots__/GraphCMSAsset.test.tsx.snap
index f3927a892..3856d6369 100644
--- a/src/components/__snapshots__/GraphCMSAsset.test.tsx.snap
+++ b/src/modules/core/gql/components/__snapshots__/GraphCMSAsset.test.tsx.snap
@@ -2,7 +2,7 @@
exports[`GraphCMSAsset should properly render an asset from GraphCMS when the asset is used as an image () when relying on default "logo" property, it should apply the internal default properties 1`] = `
) containing an image () when relying on default "logo" property 1`] = `
Object {
- "alt": "",
+ "alt": "https://media.graphcms.com/88YmsSFsSEGI9i0qcH0V",
"className": "asset-test",
"id": "test",
"src": "https://media.graphcms.com/88YmsSFsSEGI9i0qcH0V",
@@ -137,7 +137,7 @@ exports[`GraphCMSAsset when the asset is used as a link() containing an image
target="_blank"
>
=> {
+ const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const apolloClient: ApolloClient = initializeApollo();
+ const variables = {
+ customerRef,
+ };
+ const queryOptions = {
+ displayName: 'APP_WIDE_QUERY',
+ query: DEMO_STATIC_DATA_QUERY,
+ variables,
+ };
+
+ const {
+ data,
+ errors,
+ loading,
+ networkStatus,
+ ...rest
+ }: ApolloQueryResult<{
+ customer: Customer;
+ }> = await apolloClient.query(queryOptions);
+
+ if (errors) {
+ // eslint-disable-next-line no-console
+ console.error(errors);
+ throw new Error('Errors were detected in GraphQL query.');
+ }
+
+ const {
+ customer,
+ } = data || {}; // XXX Use empty object as fallback, to avoid app crash when destructuring, if no data is returned
+ const dataset = {
+ customer,
+ };
+
+ return dataset;
+};
+
+/**
+ * Pre-fetches the GraphCMS dataset and stores the result in an cached internal JSON file.
+ * Overall, this approach allows us to have some static app-wide data that will never update, and have real-time data wherever we want.
+ *
+ * This is very useful to avoid fetching the same data for each page during the build step.
+ * By default, Next.js would call the GraphCMS API once per page built.
+ * This was a huge pain for many reasons, because our app uses mostly static pages and we don't want those static pages to be updated.
+ *
+ * Also, even considering built time only, it was very inefficient, because Next was triggering too many API calls:
+ * - More than 120 fetch attempts (locales * pages)
+ * - 3 locales (in supportedLocales)
+ * - lots of static pages (40+ demo pages)
+ * - Our in-memory cache was helping but wouldn't completely conceal the over-fetching caused by Next.js
+ * - We were generating pages for all supportedLocales, even if the customer hadn't enabled some languages (longer build + undesired pages leading to bad UX)
+ * - We weren't able to auto-redirect only to one of the enabled customer locales, not without fetching GraphCMS (which is slower)
+ *
+ * The shared/static dataset is accessible to:
+ * - All components
+ * - All pages (both getStaticProps and getStaticPaths, and even in getServerSideProps is you wish to!)
+ * - All API endpoints
+ *
+ * XXX The data are therefore STALE, they're not fetched in real-time.
+ * They won't update (the app won't display up-to-date data until the next deployment, for static pages).
+ *
+ * @example const dataset: StaticDataset = await getStaticGraphcmsDataset();
+ *
+ * @see https://github.com/ricokahler/next-plugin-preval
+ */
+export default preval(fetchStaticGraphcmsDataset());
diff --git a/src/modules/core/gql/getGraphcmsDataset.ts b/src/modules/core/gql/getGraphcmsDataset.ts
new file mode 100644
index 000000000..8f32d59ef
--- /dev/null
+++ b/src/modules/core/gql/getGraphcmsDataset.ts
@@ -0,0 +1,92 @@
+import { DEMO_LAYOUT_QUERY } from '@/common/gql/demoLayoutQuery';
+import { initializeApollo } from '@/modules/core/apollo/apolloClient';
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+import { StaticDataset } from '@/modules/core/gql/types/StaticDataset';
+import { createLogger } from '@/modules/core/logging/logger';
+import {
+ ApolloClient,
+ ApolloQueryResult,
+ DocumentNode,
+ NormalizedCacheObject,
+} from '@apollo/client';
+import { QueryOptions } from '@apollo/client/core/watchQueryOptions';
+
+const fileLabel = 'modules/core/airtable/getGraphcmsDataset';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Returns the whole dataset, based on the app-wide static/shared/stale data fetched at build time.
+ *
+ * This dataset is STALE. It will not update, ever.
+ * The dataset is created at build time, using the "next-plugin-preval" webpack plugin.
+ *
+ * @example const dataset: StaticDataset = await getStaticGraphcmsDataset();
+ */
+export const getStaticGraphcmsDataset = async (): Promise => {
+ return (await import('@/modules/core/gql/fetchStaticGraphcmsDataset.preval')) as unknown as StaticDataset;
+};
+
+/**
+ * Returns the GraphCMS dataset by fetching it in real-time.
+ * It runs the DEMO_LAYOUT_QUERY query by default.
+ *
+ * This operation is expensive and might take a lot of time (several seconds).
+ * Unless absolutely necessary, using the static dataset is usually preferred.
+ *
+ * @param gcmsLocales
+ * @param query
+ */
+export const getLiveGraphcmsDataset = async (gcmsLocales: string, query?: DocumentNode): Promise => {
+ const customerRef: string = process.env.NEXT_PUBLIC_CUSTOMER_REF;
+ const apolloClient: ApolloClient = initializeApollo();
+ const variables = {
+ customerRef,
+ };
+ const queryOptions: QueryOptions & { displayName: string } = {
+ displayName: 'DEMO_LAYOUT_QUERY',
+ query: query || DEMO_LAYOUT_QUERY,
+ variables,
+ context: {
+ headers: {
+ 'gcms-locales': gcmsLocales,
+ },
+ },
+ };
+
+ const {
+ data,
+ errors,
+ loading,
+ networkStatus,
+ ...rest
+ }: ApolloQueryResult = await apolloClient.query(queryOptions);
+
+ if (errors) {
+ // eslint-disable-next-line no-console
+ console.error(errors);
+ throw new Error('Errors were detected in GraphQL query.');
+ }
+
+ return data;
+};
+
+/**
+ * Returns the Airtable dataset by either returning the static dataset (stale data) or performing a live query (real-time).
+ *
+ * @param gcmsLocales
+ * @param query
+ * @param forceRealTimeFetch
+ */
+export const getGraphcmsDataset = async (gcmsLocales: string, query?: DocumentNode, forceRealTimeFetch = false): Promise => {
+ if (forceRealTimeFetch || process.env.NODE_ENV === 'development') {
+ // When preview mode is enabled or working locally, we want to make real-time API requests to get up-to-date data
+ // Because using the "next-plugin-preval" plugin worsen developer experience in dev - See https://github.com/UnlyEd/next-right-now/discussions/335#discussioncomment-792821
+ return await getLiveGraphcmsDataset(gcmsLocales, query);
+ } else {
+ // Otherwise, we fallback to the app-wide shared/static data (stale)
+ return await getStaticGraphcmsDataset();
+ }
+};
+
diff --git a/src/utils/graphcms.test.ts b/src/modules/core/gql/graphcms.test.ts
similarity index 62%
rename from src/utils/graphcms.test.ts
rename to src/modules/core/gql/graphcms.test.ts
index 069ec1af1..1647ca403 100644
--- a/src/utils/graphcms.test.ts
+++ b/src/modules/core/gql/graphcms.test.ts
@@ -4,15 +4,18 @@ describe(`utils/graphcms.ts`, () => {
describe(`prepareGraphCMSLocaleHeader`, () => {
describe(`should clean properly the header locale`, () => {
test(`when using 1 language`, async () => {
- expect(prepareGraphCMSLocaleHeader(['fr'])).toEqual(`FR`);
+ expect(prepareGraphCMSLocaleHeader(['fr'])).toEqual(`fr`);
+ expect(prepareGraphCMSLocaleHeader(['FR'])).toEqual(`fr`);
});
test(`when using 2 languages`, async () => {
- expect(prepareGraphCMSLocaleHeader(['fr', 'en'])).toEqual(`FR, EN`);
+ expect(prepareGraphCMSLocaleHeader(['fr', 'en'])).toEqual(`fr, en`);
+ expect(prepareGraphCMSLocaleHeader(['FR', 'EN'])).toEqual(`fr, en`);
});
test(`when using 3 languages`, async () => {
- expect(prepareGraphCMSLocaleHeader(['fr', 'en', 'es'])).toEqual(`FR, EN, ES`);
+ expect(prepareGraphCMSLocaleHeader(['fr', 'en', 'es'])).toEqual(`fr, en, es`);
+ expect(prepareGraphCMSLocaleHeader(['FR', 'EN', 'ES'])).toEqual(`fr, en, es`);
});
});
});
diff --git a/src/modules/core/gql/graphcms.ts b/src/modules/core/gql/graphcms.ts
new file mode 100644
index 000000000..ecde017f5
--- /dev/null
+++ b/src/modules/core/gql/graphcms.ts
@@ -0,0 +1,20 @@
+/**
+ * GraphCMS country codes separator expected in the header
+ *
+ * @see https://graphcms.com/docs/content-api/localization#http-header
+ * @type {string}
+ */
+export const LANGUAGES_SEP = ', ';
+
+/**
+ * Convert an array of languages into a GraphCMS-compatible locale header
+ * @see https://graphcms.com/docs/content-api/localization#http-header
+ *
+ * XXX Beware, lowercase is very important as it will completely crash if not lower-cased! (e.g: "ApolloError: Network error: Response not successful: Received status code 500")
+ *
+ * @param {string[]} languages
+ * @return {string}
+ */
+export const prepareGraphCMSLocaleHeader = (languages: string[]): string => {
+ return languages.join(LANGUAGES_SEP).toLowerCase();
+};
diff --git a/src/modules/core/gql/types/ApolloQueryOptions.ts b/src/modules/core/gql/types/ApolloQueryOptions.ts
new file mode 100644
index 000000000..7f9a64f5e
--- /dev/null
+++ b/src/modules/core/gql/types/ApolloQueryOptions.ts
@@ -0,0 +1,5 @@
+import { QueryOptions } from '@apollo/client';
+
+export type ApolloQueryOptions = QueryOptions & {
+ displayName: string; // Missing in official definition
+}
diff --git a/src/modules/core/gql/types/StaticDataset.ts b/src/modules/core/gql/types/StaticDataset.ts
new file mode 100644
index 000000000..a20eb9569
--- /dev/null
+++ b/src/modules/core/gql/types/StaticDataset.ts
@@ -0,0 +1,6 @@
+import { Customer } from '@/modules/core/data/types/Customer';
+
+export type StaticCustomer = Pick;
+export type StaticDataset = {
+ customer: StaticCustomer;
+};
diff --git a/src/modules/core/i18n/components/I18nBtnChangeLocale.tsx b/src/modules/core/i18n/components/I18nBtnChangeLocale.tsx
new file mode 100644
index 000000000..46ef07447
--- /dev/null
+++ b/src/modules/core/i18n/components/I18nBtnChangeLocale.tsx
@@ -0,0 +1,75 @@
+import startsWith from 'lodash.startswith';
+import {
+ NextRouter,
+ useRouter,
+} from 'next/router';
+import React from 'react';
+import useI18n, { I18n } from '../hooks/useI18n';
+import { LANG_FR } from '../i18n';
+import { i18nRedirect } from '../i18nRouter';
+import ToggleLanguagesButton from './ToggleLanguagesButton';
+
+type Props = {
+ /**
+ * HTML id attribute. Must be unique.
+ */
+ id: string;
+
+ /**
+ * Click event handler. Changes the select language.
+ *
+ * Doesn't work on Storybook, but you can change the language with the top toolbar item. [See issue](https://github.com/storybookjs/storybook/issues/13634).
+ *
+ * @default defaultHandleClick
+ */
+ onClick?: (any) => void;
+}
+
+/**
+ * XXX Implementation is being kept simple for the sake of simplicity (it toggles selected language between fr/en)
+ * It doesn't match a real-world use case because there are many possible variations and we can't cover them all
+ * e.g: with country-based locales (fr-FR, en-GB) or without (fr, en)
+ *
+ * @param currentLocale
+ * @param router
+ */
+const defaultHandleClick = (currentLocale: string, router): void => {
+ const newLocale = startsWith(currentLocale, 'fr') ? 'en' : 'fr';
+ i18nRedirect(newLocale, router);
+};
+
+/**
+ * Button that changes the current language used by the application
+ *
+ * @param props
+ */
+const I18nBtnChangeLocale: React.FunctionComponent = (props): JSX.Element => {
+ const {
+ id,
+ } = props;
+ let {
+ onClick,
+ } = props;
+ const {
+ lang,
+ locale,
+ }: I18n = useI18n();
+ const router: NextRouter = useRouter();
+
+ if (!onClick) {
+ onClick = (): void => {
+ defaultHandleClick(locale, router);
+ };
+ }
+
+ return (
+
+ );
+};
+
+export default I18nBtnChangeLocale;
diff --git a/src/modules/core/i18n/components/I18nLink.test.tsx b/src/modules/core/i18n/components/I18nLink.test.tsx
new file mode 100644
index 000000000..a856aac72
--- /dev/null
+++ b/src/modules/core/i18n/components/I18nLink.test.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import TestRenderer from 'react-test-renderer';
+import { NavLink } from 'reactstrap';
+import i18nContext from '../contexts/i18nContext';
+import I18nLink from './I18nLink';
+
+/**
+ * @group unit
+ * @group components
+ */
+describe('I18nLink', () => {
+ beforeEach(() => {
+ global.console = global.muteConsole();
+ });
+
+ const I18nLinkTest = (props) => {
+ const {
+ locale = 'en',
+ href,
+ text = 'Text',
+ ...rest
+ } = props;
+
+ return (
+
+
+ {text}
+
+
+ );
+ };
+
+ describe('should properly render', () => {
+ test('when going to homepage, it should redirect to i18n homepage', () => {
+ const renderer = TestRenderer.create();
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Text']);
+ expect(link.props.href).toEqual('/en');
+ expect(link).toMatchSnapshot();
+ });
+
+ test('when using custom CSS class', () => {
+ const renderer = TestRenderer.create();
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Text']);
+ expect(link.props.href).toEqual('/en');
+ expect(link.props.className).toEqual('customClassName');
+ expect(link).toMatchSnapshot();
+ });
+
+ test('when forcing the locale to use', () => {
+ const renderer = TestRenderer.create();
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Text']);
+ expect(link.props.href).toEqual('/fr-FR');
+ expect(link).toMatchSnapshot();
+ });
+
+ test('when using wrapChildrenAsLink manually using a element', () => {
+ const renderer = TestRenderer.create(Page} />);
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Page']);
+ expect(link.props.href).toEqual('/en');
+ expect(link).toMatchSnapshot();
+ });
+
+ test('when using wrapChildrenAsLink manually using a element', () => {
+ const renderer = TestRenderer.create(Page} />);
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Page']);
+ expect(link.props.href).toEqual('/en');
+ expect(link.props.className).toEqual('nav-link');
+ expect(link).toMatchSnapshot();
+ });
+
+ test('when using route params', () => {
+ const renderer = TestRenderer.create();
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Text']);
+ expect(link.props.href).toEqual('/en/products/5');
+ expect(link).toMatchSnapshot();
+ });
+
+ test('when using route params and query route params', () => {
+ const renderer = TestRenderer.create();
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Text']);
+ expect(link.props.href).toEqual('/en/products/5?userId=1');
+ expect(link).toMatchSnapshot();
+ });
+
+ test('when using route params and query route params using nested paths', () => {
+ const renderer = TestRenderer.create();
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Text']);
+ expect(link.props.href).toEqual('/en/products/favourites/5?userId=1');
+ expect(link).toMatchSnapshot();
+ });
+
+ test('when using route params and query route params using nested paths and forcing locale', () => {
+ const renderer = TestRenderer.create();
+ const link: any = renderer.toJSON();
+
+ expect(link.type).toEqual('a');
+ expect(link.children).toEqual(['Text']);
+ expect(link.props.href).toEqual('/fr/products/favourites/5?userId=1');
+ expect(link).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/modules/core/i18n/components/I18nLink.tsx b/src/modules/core/i18n/components/I18nLink.tsx
new file mode 100644
index 000000000..47375caec
--- /dev/null
+++ b/src/modules/core/i18n/components/I18nLink.tsx
@@ -0,0 +1,219 @@
+import isArray from 'lodash.isarray';
+import isEmpty from 'lodash.isempty';
+import map from 'lodash.map';
+import NextLink from 'next/link';
+import React from 'react';
+import useI18n, { I18n } from '../hooks/useI18n';
+import {
+ I18nRoute,
+ resolveI18nRoute,
+} from '../i18nRouter';
+
+type ParamValueToForward = string | number | Array;
+
+type Props = {
+ /**
+ * Optional decorator for the path that will be shown in the browser URL bar.
+ */
+ as?: string;
+
+ /**
+ * React node as children.
+ */
+ children: React.ReactNode;
+
+ /**
+ * Additional CSS classes.
+ */
+ className?: string;
+
+ /**
+ * The path or URL to navigate to.
+ */
+ href: string;
+
+ /**
+ * The active locale is automatically prepended. locale allows for providing a different locale.
+ *
+ * @default current locale
+ */
+ locale?: string; // The locale can be specified, but it'll fallback to the current locale if unspecified
+
+ /**
+ * Parameters to inject into the url, necessary when using route params (other than `locale`).
+ *
+ * Example:
+ *
+ * `/products/[id]` with `params={{ id: 5 }}` becomes `/products/5`
+ */
+ params?: { [key: string]: ParamValueToForward };
+
+ /**
+ * Forces Link to send the href property to its child.
+ *
+ * @default false
+ */
+ passHref?: boolean;
+
+ /**
+ * Prefetch the page in the background.
+ * Any that is in the viewport (initially or through scroll) will be preloaded.
+ * Prefetch can be disabled by passing prefetch={false}.
+ * Pages using Static Generation will preload JSON files with the data for faster page transitions.
+ *
+ * @default true
+ */
+ prefetch?: boolean;
+
+ /**
+ * Query to inject to the url, necessary when using route query param.
+ *
+ * Example:
+ *
+ * `/products` with `query={{ userId: 1 }}` becomes `/products?userId=1`
+ */
+ query?: { [key: string]: ParamValueToForward };
+
+ /**
+ * Replace the current history state instead of adding a new url into the stack.
+ *
+ * @default false
+ */
+ replace?: boolean;
+
+ /**
+ * Scroll to the top of the page after a navigation.
+ *
+ * @default true
+ */
+ scroll?: boolean;
+
+ /**
+ * Update the path of the current page without rerunning `getStaticProps`, `getServerSideProps` or `getInitialProps`.
+ *
+ * @default false
+ */
+ shallow?: boolean;
+
+ /**
+ * Disabled on Storybook, as it crashes the UI.
+ */
+ wrapChildrenAsLink?: boolean; // Helper to avoid writing redundant code
+};
+
+/**
+ * Wrapper around the native Next.js component. Handles localised links.
+ *
+ * Uses the current active locale by default.
+ *
+ * [Read why we don't use the official Next.js `Link` component](https://unlyed.github.io/next-right-now/guides/i18n/#official-i18n-routing-implementation) Tip
+ *
+ * @example Recommended usage
+ * Homepage
+ * Homepage
+ *
+ * @example When specifying the locale to use (overrides default)
+ * Homepage in "fr-FR" locale
+ *
+ * @example The recommended usage is equivalent to this (wrapChildrenAsLink is true, by default)
+ * Homepage
+ * Homepage
+ *
+ * @example When using children that use a tag, you must set wrapChildrenAsLink to false to avoid using a link within a link
+ *
+ * Homepage
+ *
+ *
+ * @example When using route params (other than "locale"). Ex: /products/5
+ *
+ * Go to product 5
+ *
+ *
+ * @example When using route query params. Ex: /products/5?userId=1
+ *
+ * Go to product 5 with userId 1
+ *
+ *
+ * @param props
+ */
+const I18nLink: React.FunctionComponent = (props): JSX.Element => {
+ const { locale: currentLocale }: I18n = useI18n();
+ const {
+ as,
+ children,
+ className,
+ href,
+ locale = currentLocale,
+ params,
+ passHref = true,
+ query,
+ wrapChildrenAsLink = true,
+ ...rest // Should only contain valid next/Link props
+ } = props;
+ let {
+ i18nHref, // eslint-disable-line prefer-const
+ i18nAs,
+ }: I18nRoute = resolveI18nRoute({
+ as,
+ href,
+ locale,
+ });
+
+ if (!isEmpty(params)) {
+ // If any params are provided, replace their name by the provided value
+ map(params, (value: ParamValueToForward, key: string | number) => {
+ if (isArray(value)) {
+ value = value.join(',');
+ }
+ i18nAs = i18nAs.replace(`[${key}]`, value as string);
+ });
+ }
+
+ if (!isEmpty(query)) {
+ // If any query params are provided, append it to `as`, so it gets forwarded;
+ const queryParamsString = Object.keys(query)
+ .map((k) => {
+ if (isArray(k)) {
+ k = k.join(',');
+ }
+ return `${k}=${query[k]}`;
+ })
+ .join('&');
+ i18nHref += `?${queryParamsString}`;
+ i18nAs += `?${queryParamsString}`;
+ }
+
+ return (
+
+ {wrapChildrenAsLink ? (
+ // eslint-disable-next-line jsx-a11y/anchor-is-valid
+ {children}
+ ) : (
+ React.cloneElement(children as React.ReactElement)
+ )}
+
+ );
+};
+
+export default I18nLink;
diff --git a/src/modules/core/i18n/components/ToggleLanguagesButton.tsx b/src/modules/core/i18n/components/ToggleLanguagesButton.tsx
new file mode 100644
index 000000000..5a1f01e9a
--- /dev/null
+++ b/src/modules/core/i18n/components/ToggleLanguagesButton.tsx
@@ -0,0 +1,65 @@
+import ToggleButton from '@/components/dataDisplay/ToggleButton';
+import React from 'react';
+
+type Props = {
+ /**
+ * HTML id attribute. Must be unique.
+ *
+ * XXX The component will not be interactive without a unique id!
+ */
+ id: string;
+
+ /**
+ * Flag used when the toggle button is checked.
+ */
+ flagOn?: string;
+
+ /**
+ * Flag used when the toggle button is not checked.
+ */
+ flagOff?: string;
+
+ /**
+ * Whether the toggle is checked.
+ *
+ * Should be a controlled property.
+ */
+ isChecked?: boolean;
+} & React.HTMLProps;
+
+/**
+ * Button that toggles between two language flags.
+ *
+ * Use a SVG encoder for SVGs:
+ *
+ * - https://yoksel.github.io/url-encoder/ Copy the SVG code and take the "Ready for CSS" value (without the "background-color" part)
+ */
+const ToggleLanguagesButton: React.FunctionComponent = (props): JSX.Element => {
+ const frenchFlagSvg = `url("data:image/svg+xml,%3Csvg id='Calque_1' data-name='Calque 1' xmlns='http://www.w3.org/2000/svg' width='24.43' height='12.91' viewBox='0 0 24.43 12.91'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23012169;%7D.cls-2%7Bfill:%23c8102e;%7D%3C/style%3E%3C/defs%3E%3Crect class='cls-1' width='7.98' height='12.91'/%3E%3Crect class='cls-2' x='16.45' width='7.98' height='12.91'/%3E%3C/svg%3E")`;
+ // Use English UK or English Hybrid at your convenience
+ const englishFlagSvg = `url("data:image/svg+xml,%3Csvg id='Calque_1' data-name='Calque 1' xmlns='http://www.w3.org/2000/svg' width='24.43' height='12.91' viewBox='0 0 24.43 12.91'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23c7102e;%7D.cls-2%7Bfill:%23faf9fa;%7D.cls-3%7Bfill:%23012068;%7D%3C/style%3E%3C/defs%3E%3Cg id='_3VsY8T' data-name='3VsY8T'%3E%3Cpath class='cls-1' d='M0,7.71V5.2l.42,0c3.39,0,6.79,0,10.18,0,.31,0,.38-.1.38-.41C11,3.19,11,1.59,11,0h2.45c0,1.59,0,3.19,0,4.78,0,.31.07.41.38.41,3,0,6,0,9.07,0l1.53,0V7.71l-.42,0c-3.39,0-6.79,0-10.18,0-.31,0-.38.1-.38.41,0,1.59,0,3.19,0,4.78H11c0-1.59,0-3.19,0-4.78,0-.31-.07-.41-.38-.41-3,0-6,0-9.07,0Z'/%3E%3Cpath class='cls-2' d='M11,0c0,1.59,0,3.19,0,4.78,0,.31-.07.41-.38.41-3.39,0-6.79,0-10.18,0L0,5.2V4.3H5.28V4.23L0,1.46V1c1.85,1,3.73,1.92,5.56,3A4.36,4.36,0,0,0,8,4.27a1,1,0,0,0-.22-.15c-1.7-.9-3.4-1.79-5.09-2.7C1.83,1,.93.54.09,0H2.86c.05.14.19.16.29.21l3.71,2,3.28,1.73V0Z'/%3E%3Cpath class='cls-2' d='M13.44,12.91c0-1.59,0-3.19,0-4.78,0-.31.07-.41.38-.41,3.39,0,6.79,0,10.18,0l.42,0v.9H19.15v.07l5.27,2.77v.49c-1.85-1-3.73-1.92-5.56-3a4.36,4.36,0,0,0-2.43-.34,1,1,0,0,0,.22.15c1.7.9,3.4,1.79,5.09,2.7.87.47,1.77.88,2.61,1.42H21.57c-.06-.15-.21-.17-.33-.23l-3.81-2L14.29,9v3.91Z'/%3E%3Cpath class='cls-2' d='M0,7.71l1.53,0c3,0,6.05,0,9.07,0,.31,0,.38.1.38.41,0,1.59,0,3.19,0,4.78h-.85V9L10,9.09,3,12.77c-.06,0-.16,0-.16.14H1.93c.06-.14.2-.16.3-.22L8.55,9.35l1.29-.69A9.51,9.51,0,0,0,8.79,8.6a2,2,0,0,0-1.2.3C6.06,9.73,4.51,10.53,3,11.35c-1,.52-1.95,1-2.89,1.56-.16-.15-.06-.35-.07-.53s0-.62,0-.93L5.3,8.68l0-.07H0Z'/%3E%3Cpath class='cls-2' d='M24.41,5.2l-1.53,0c-3,0-6.05,0-9.07,0-.31,0-.38-.1-.38-.41,0-1.59,0-3.19,0-4.78h.85V3.9l.18-.08L21.41.14c.06,0,.16,0,.16-.14h.93c0,.09-.08.1-.13.12l-7.49,4c-.09.06-.26,0-.28.23.31,0,.62,0,.93,0A2.32,2.32,0,0,0,16.91,4c1.52-.83,3.05-1.62,4.58-2.44,1-.51,1.92-1,2.85-1.54.16.15.06.35.07.53s0,.62,0,.93L19.13,4.23l0,.07h5.25Z'/%3E%3Cpath class='cls-3' d='M10.14,0V3.91L6.86,2.18,3.15.21c-.1,0-.24-.07-.29-.21Z'/%3E%3Cpath class='cls-3' d='M21.57,0c0,.11-.1.11-.16.14L14.47,3.82l-.18.08V0Z'/%3E%3Cpath class='cls-3' d='M2.86,12.91c0-.11.1-.11.16-.14L10,9.09,10.14,9v3.9Z'/%3E%3Cpath class='cls-3' d='M14.29,12.91V9l3.14,1.66,3.81,2c.12.06.27.08.33.23Z'/%3E%3Cpath class='cls-1' d='M24.34,0c-.93.57-1.9,1-2.85,1.54C20,2.36,18.43,3.15,16.91,4a2.32,2.32,0,0,1-1.38.33c-.31,0-.62,0-.93,0,0-.2.19-.17.28-.23l7.49-4c.05,0,.12,0,.13-.12Z'/%3E%3Cpath class='cls-1' d='M.09,12.91C1,12.33,2,11.87,3,11.35c1.53-.82,3.08-1.62,4.61-2.45a2,2,0,0,1,1.2-.3,9.51,9.51,0,0,1,1.05.06l-1.29.69L2.23,12.69c-.1.06-.24.08-.3.22Z'/%3E%3Cpath class='cls-3' d='M0,1.46,5.29,4.23V4.3H0Z'/%3E%3Cpath class='cls-3' d='M24.41,4.3H19.16l0-.07,5.28-2.77Z'/%3E%3Cpath class='cls-3' d='M0,8.61H5.27l0,.07L0,11.45Z'/%3E%3Cpath class='cls-3' d='M24.41,11.45,19.14,8.68V8.61h5.26Z'/%3E%3Cpath class='cls-1' d='M.09,0C.93.54,1.83,1,2.7,1.42c1.69.91,3.39,1.8,5.09,2.7A1,1,0,0,1,8,4.27a4.36,4.36,0,0,1-2.43-.34C3.75,2.89,1.87,2,0,1,.06.65-.07.31.09,0Z'/%3E%3Cpath class='cls-1' d='M24.34,12.91c-.84-.54-1.74-.95-2.61-1.42-1.69-.91-3.39-1.8-5.09-2.7a1,1,0,0,1-.22-.15A4.36,4.36,0,0,1,18.85,9c1.83,1,3.71,2,5.56,3C24.37,12.26,24.5,12.6,24.34,12.91Z'/%3E%3C/g%3E%3C/svg%3E")`;
+ const englishHybridFlagSvg = `url("data:image/svg+xml,%3Csvg id='Calque_1' data-name='Calque 1' xmlns='http://www.w3.org/2000/svg' width='24.43' height='12.91' viewBox='0 0 24.39 12.88'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%23bf2333;%7D.cls-2,.cls-4%7Bfill:%23fff;%7D.cls-3%7Bfill:%23223164;%7D.cls-4%7Bfill-rule:evenodd;%7D.cls-5%7Bfill:%23283575;%7D.cls-6%7Bfill:%23d0232e;%7D%3C/style%3E%3C/defs%3E%3Crect id='rect8767' class='cls-1' x='0.03' y='11.75' width='24.26' height='1.03'/%3E%3Crect id='rect8769' class='cls-2' x='0.03' y='10.72' width='24.26' height='1.03'/%3E%3Crect id='rect8771' class='cls-1' x='0.03' y='9.7' width='24.26' height='1.03'/%3E%3Crect id='rect8773' class='cls-2' x='0.03' y='8.68' width='24.26' height='1.03'/%3E%3Crect id='rect8775' class='cls-1' x='0.03' y='7.72' width='24.26' height='0.96'/%3E%3Crect id='rect8777' class='cls-2' x='0.03' y='6.76' width='24.26' height='0.96'/%3E%3Crect id='rect8779' class='cls-1' x='0.03' y='5.79' width='24.26' height='0.96'/%3E%3Crect id='rect8781' class='cls-2' x='0.03' y='4.83' width='24.26' height='0.96'/%3E%3Crect id='rect8783' class='cls-1' x='0.03' y='3.87' width='24.26' height='0.96'/%3E%3Crect id='rect8785' class='cls-2' x='0.03' y='2.92' width='24.26' height='0.96'/%3E%3Crect id='rect8787' class='cls-1' x='0.03' y='1.96' width='24.26' height='0.96'/%3E%3Crect id='rect8791' class='cls-2' x='0.03' y='0.99' width='24.26' height='0.96'/%3E%3Crect id='rect8793' class='cls-1' x='0.03' y='0.03' width='24.26' height='0.96'/%3E%3Crect id='rect8796' class='cls-3' x='0.03' y='0.03' width='9.73' height='6.72'/%3E%3Cpath id='path8825' class='cls-4' d='M.44,6H.72l.11-.28L.94,6h.28L1,6.14l.07.29L.83,6.28l-.24.16.06-.29Z'/%3E%3Cpath id='path8827' class='cls-4' d='M2,6h.28l.11-.28L2.54,6h.29l-.21.19.06.29-.24-.15-.23.16.06-.29Z'/%3E%3Cpath id='path8829' class='cls-4' d='M3.7,6H4l.11-.28L4.2,6h.29l-.22.19.07.29-.24-.2-.25.16.06-.29Z'/%3E%3Cpath id='path8831' class='cls-4' d='M5.3,6h.28l.11-.28L5.81,6h.28l-.21.19.06.29-.24-.2-.24.16.06-.29Z'/%3E%3Cpath id='path8833' class='cls-4' d='M6.9,6h.29l.1-.28L7.41,6h.28l-.21.19.07.29-.25-.2-.24.16.06-.29Z'/%3E%3Cpath id='path8835' class='cls-4' d='M8.57,6h.28L9,5.68,9.08,6h.27l-.16.19v.3L9,6.29l-.24.16.06-.29Z'/%3E%3Cpath id='path8837' class='cls-4' d='M1.27,5.32h.28L1.66,5l.12.27h.28l-.21.2.06.29-.24-.15-.24.16.06-.29Z'/%3E%3Cpath id='path8839' class='cls-4' d='M.44,4.61H.72l.11-.28.11.28h.28L1,4.81l.07.29L.83,5l-.24.1.06-.29Z'/%3E%3Cpath id='path8841' class='cls-4' d='M1.27,4h.28l.11-.28L1.78,4h.28l-.21.2.06.29-.24-.15-.24.15.06-.29Z'/%3E%3Cpath id='path8843' class='cls-4' d='M.44,3.26H.72L.83,3l.11.26h.28L1,3.46l.07.29L.83,3.6l-.24.16.06-.29Z'/%3E%3Cpath id='path8845' class='cls-4' d='M1.27,2.63h.28l.11-.27.12.27h.28l-.21.2.06.28L1.67,3l-.24.15.06-.29Z'/%3E%3Cpath id='path8847' class='cls-4' d='M.44,1.93H.72l.11-.28.11.27h.28L1,2.11l.07.29L.83,2.25l-.24.16.06-.29Z'/%3E%3Cpath id='path8849' class='cls-4' d='M1.27,1.29h.28L1.66,1l.12.27h.28l-.21.2.06.29-.24-.15-.24.16.06-.29Z'/%3E%3Cpath id='path8851' class='cls-4' d='M.44.58H.72L.83.3.94.58h.28L1,.78l.07.29L.83.92l-.24.15L.65.78Z'/%3E%3Cpath id='path8853' class='cls-4' d='M2.87,5.32h.28L3.25,5l.12.27h.28l-.21.2.07.29-.25-.15L3,5.81l0-.29Z'/%3E%3Cpath id='path8855' class='cls-4' d='M2,4.61h.28l.11-.28.11.28h.29l-.21.2.06.29L2.44,5l-.24.1.06-.29Z'/%3E%3Cpath id='path8857' class='cls-4' d='M2.87,4h.28l.1-.28L3.37,4h.28l-.21.2.07.29-.25-.15L3,4.46l0-.29Z'/%3E%3Cpath id='path8859' class='cls-4' d='M2,3.26h.28L2.43,3l.11.26h.29l-.21.2.06.29L2.44,3.6l-.24.16.06-.29Z'/%3E%3Cpath id='path8861' class='cls-4' d='M2.87,2.63h.28l.1-.27.12.27h.28l-.21.2.07.28L3.26,3,3,3.12l0-.29Z'/%3E%3Cpath id='path8863' class='cls-4' d='M2,1.93h.28l.11-.28.11.27h.29l-.21.19.06.29L2.4,2.25l-.23.2.06-.29Z'/%3E%3Cpath id='path8865' class='cls-4' d='M2.87,1.29h.28L3.25,1l.12.27h.28l-.21.2.07.29-.25-.15L3,1.78l0-.29Z'/%3E%3Cpath id='path8867' class='cls-4' d='M2,.58h.28L2.43.3l.11.28h.29l-.21.2.06.29L2.44.92l-.24.15L2.26.78Z'/%3E%3Cpath id='path8869' class='cls-4' d='M4.47,5.32h.28L4.86,5,5,5.31h.28L5,5.51l.07.29-.25-.15-.24.16.06-.29Z'/%3E%3Cpath id='path8871' class='cls-4' d='M3.7,4.61H4l.11-.28.11.28h.29l-.22.2.07.29L4.1,5l-.25.15.06-.29Z'/%3E%3Cpath id='path8873' class='cls-4' d='M6.9,4.61h.29l.1-.28.12.28h.28l-.21.2.07.29L7.3,5l-.24.15.06-.29Z'/%3E%3Cpath id='path8875' class='cls-4' d='M5.3,4.61h.28l.11-.28.12.28h.28l-.21.2.06.29L5.7,5l-.24.15.06-.29Z'/%3E%3Cpath id='path8877' class='cls-4' d='M7.74,5.32H8L8.13,5l.11.27h.29l-.22.2.07.29-.24-.15-.25.16L8,5.52Z'/%3E%3Cpath id='path8879' class='cls-4' d='M6.13,5.32h.29L6.52,5l.12.27h.28l-.21.2.07.29-.25-.15-.24.16.06-.29Z'/%3E%3Cpath id='path8881' class='cls-4' d='M3.7.58H4L4.09.3,4.2.58h.29l-.22.2.07.29L4.1.92l-.25.15L3.91.78Z'/%3E%3Cpath id='path8883' class='cls-4' d='M4.47,1.29h.28L4.86,1,5,1.28h.28L5,1.48l.07.29-.25-.15-.24.16.06-.29Z'/%3E%3Cpath id='path8885' class='cls-4' d='M3.7,1.93H4l.11-.28.11.27h.29l-.22.19.07.29L4.1,2.25l-.25.16.06-.29Z'/%3E%3Cpath id='path8887' class='cls-4' d='M4.47,2.63h.28l.11-.27L5,2.63h.28L5,2.83l.07.28L4.86,3l-.24.15.06-.29Z'/%3E%3Cpath id='path8889' class='cls-4' d='M3.7,3.26H4L4.09,3l.11.26h.29l-.22.2.07.29L4.1,3.6l-.25.16.06-.29Z'/%3E%3Cpath id='path8891' class='cls-4' d='M4.47,4h.28l.11-.28L5,4h.28L5,4.17l.07.29-.25-.15-.24.15.06-.29Z'/%3E%3Cpath id='path8893' class='cls-4' d='M8.57,4.61h.28L9,4.33l.12.28h.27l-.2.2V5.1L9,5l-.24.15.06-.29Z'/%3E%3Cpath id='path8895' class='cls-4' d='M5.3,3.26h.28L5.69,3l.12.26h.28l-.21.2.06.29L5.7,3.6l-.24.16.06-.29Z'/%3E%3Cpath id='path8897' class='cls-4' d='M5.3.58h.28L5.69.3l.12.28h.28l-.21.2.06.29L5.7.92l-.24.15L5.52.78Z'/%3E%3Cpath id='path8899' class='cls-4' d='M5.3,1.93h.28l.11-.28.12.27h.28l-.21.19.06.29L5.7,2.25l-.24.16.06-.29Z'/%3E%3Cpath id='path8901' class='cls-4' d='M7.74,4H8l.11-.28L8.24,4h.29l-.22.2.07.29-.24-.15-.25.15L8,4.17Z'/%3E%3Cpath id='path8903' class='cls-4' d='M6.13,4h.29l.1-.28L6.64,4h.28l-.21.2.07.29-.25-.15-.24.15.06-.29Z'/%3E%3Cpath id='path8905' class='cls-4' d='M6.13,1.29h.29L6.52,1l.12.27h.28l-.21.2.07.29-.25-.15-.24.16.06-.29Z'/%3E%3Cpath id='path8907' class='cls-4' d='M6.13,2.63h.29l.1-.27.12.27h.28l-.21.2.07.28L6.53,3l-.24.15.06-.29Z'/%3E%3Cpath id='path8909' class='cls-4' d='M8.57,3.26h.28L9,3l.12.26h.27l-.16.18.06.29L9,3.58l-.24.16.06-.29Z'/%3E%3Cpath id='path8911' class='cls-4' d='M6.9,3.26h.29L7.29,3l.12.26h.28l-.21.2.07.29L7.3,3.6l-.24.16.06-.29Z'/%3E%3Cpath id='path8913' class='cls-4' d='M6.9.58h.29L7.29.3l.12.28h.28l-.21.2.07.29L7.3.92l-.24.15L7.12.78Z'/%3E%3Cpath id='path8915' class='cls-4' d='M6.9,1.93h.29l.1-.28.12.27h.28l-.21.19.07.29L7.3,2.25l-.24.16.06-.29Z'/%3E%3Cpath id='path8917' class='cls-4' d='M8.57.58h.28L9,.3l.12.28h.27l-.2.2v.29L9,.92l-.24.15,0-.29Z'/%3E%3Cpath id='path8919' class='cls-4' d='M7.74,1.29H8L8.13,1l.11.27h.29l-.22.2.07.29-.24-.15-.25.16L8,1.49Z'/%3E%3Cpath id='path8921' class='cls-4' d='M8.57,1.93h.28L9,1.65l.12.27h.27l-.2.19v.34L9,2.3l-.24.16.06-.29Z'/%3E%3Cpath id='path8923' class='cls-4' d='M7.74,2.63H8l.11-.27.11.27h.29l-.22.2.07.28L8.14,3l-.25.15L8,2.83Z'/%3E%3Cpath id='path3835' class='cls-2' d='M0,12.8,24.39.08V12.83Z'/%3E%3Cpath id='path3839' class='cls-5' d='M14.19,12.78V8.13h10.1v4.63Z'/%3E%3Cpath id='path3843' class='cls-5' d='M24.29.05,16,4.44h8.25Z'/%3E%3Cpath id='path3845' class='cls-5' d='M0,12.77l9.1-4.7h1.25v4.72Z'/%3E%3Cpath id='path3847' class='cls-2' d='M24.35,11.5v1.38H22.17L13.85,8.52l.09-.61,3.69.1Z'/%3E%3Cpath id='path3855' class='cls-2' d='M24.37,0,15.82,4.54l2.65.06,5.85-3.15Z'/%3E%3Cpath id='path3859' class='cls-2' d='M0,12.77,9.51,7.85h1v.93l-7.46,4Z'/%3E%3Cpath id='path4355' class='cls-6' d='M0,12.77,9.36,7.92h1v.5L2.08,12.76Z'/%3E%3Cpath id='path4357' class='cls-6' d='M15.27,8l1.84.05,7.2,3.85v.84Z'/%3E%3Cpath id='path3058' class='cls-6' d='M24.29,7.44h-14L15,5.06h9.36Z'/%3E%3Cpath id='path3060' class='cls-6' d='M13.42,12.74V5.85L11,7.08v5.69Z'/%3E%3C/svg%3E")`;
+ const {
+ id,
+ flagOn = frenchFlagSvg,
+ flagOff = englishHybridFlagSvg,
+ isChecked,
+ ...rest
+ } = props;
+
+ return (
+
+ );
+};
+
+export default ToggleLanguagesButton;
diff --git a/src/modules/core/i18n/components/__snapshots__/I18nLink.test.tsx.snap b/src/modules/core/i18n/components/__snapshots__/I18nLink.test.tsx.snap
new file mode 100644
index 000000000..7ac1b1959
--- /dev/null
+++ b/src/modules/core/i18n/components/__snapshots__/I18nLink.test.tsx.snap
@@ -0,0 +1,93 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`I18nLink should properly render when forcing the locale to use 1`] = `
+
+ Text
+
+`;
+
+exports[`I18nLink should properly render when going to homepage, it should redirect to i18n homepage 1`] = `
+
+ Text
+
+`;
+
+exports[`I18nLink should properly render when using custom CSS class 1`] = `
+
+ Text
+
+`;
+
+exports[`I18nLink should properly render when using route params 1`] = `
+
+ Text
+
+`;
+
+exports[`I18nLink should properly render when using route params and query route params 1`] = `
+
+ Text
+
+`;
+
+exports[`I18nLink should properly render when using route params and query route params using nested paths 1`] = `
+
+ Text
+
+`;
+
+exports[`I18nLink should properly render when using route params and query route params using nested paths and forcing locale 1`] = `
+
+ Text
+
+`;
+
+exports[`I18nLink should properly render when using wrapChildrenAsLink manually using a element 1`] = `
+
+ Page
+
+`;
+
+exports[`I18nLink should properly render when using wrapChildrenAsLink manually using a element 1`] = `
+
+ Page
+
+`;
diff --git a/src/modules/core/i18n/contexts/i18nContext.tsx b/src/modules/core/i18n/contexts/i18nContext.tsx
new file mode 100644
index 000000000..9cf4cb100
--- /dev/null
+++ b/src/modules/core/i18n/contexts/i18nContext.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+export type I18nContext = {
+ locale: string; // e.g: fr, fr-FR, en, en-US, en-GB
+ lang: string; // e.g: fr, en
+}
+
+/**
+ * Uses native React Context API
+ *
+ * @example Usage
+ * import i18nContext from './src/stores/i18nContext';
+ * const { locale, lang }: I18nContext = React.useContext(i18nContext);
+ *
+ * @see https://reactjs.org/docs/context.html
+ * @see https://medium.com/better-programming/react-hooks-usecontext-30eb560999f for useContext hook example (open in anonymous browser #paywall)
+ */
+export const i18nContext = React.createContext(null);
+
+export default i18nContext;
diff --git a/src/modules/core/i18n/fetchLocizeTranslations.ts b/src/modules/core/i18n/fetchLocizeTranslations.ts
new file mode 100644
index 000000000..543816ff9
--- /dev/null
+++ b/src/modules/core/i18n/fetchLocizeTranslations.ts
@@ -0,0 +1,51 @@
+import { supportedLocales } from '@/modules/core/i18n/i18nConfig';
+import {
+ fetchTranslations,
+ I18nextResources,
+} from '@/modules/core/i18n/i18nextLocize';
+import { I18nLocale } from '@/modules/core/i18n/types/I18nLocale';
+import { createLogger } from '@/modules/core/logging/logger';
+
+const fileLabel = 'modules/core/i18n/fetchLocizeTranslations';
+const logger = createLogger({
+ fileLabel,
+});
+
+export type LocizeTranslationByLang = {
+ [lang: string]: I18nextResources;
+}
+
+/**
+ * Fetches the Locize API.
+ * Invoked by fetchLocizeTranslations.preval.preval.ts file at build time (during Webpack bundling).
+ *
+ * Disabled during development, because it invokes too many API calls, the SSG pages are configured to fetch real-time data during development.
+ * See https://github.com/ricokahler/next-plugin-preval/issues/27
+ *
+ * XXX Must be a single export file otherwise it can cause issues - See https://github.com/ricokahler/next-plugin-preval/issues/19#issuecomment-848799473
+ *
+ * XXX We opinionately decided to use the "lang" (e.g: 'en') as Locize index, but it could also be the "name" (e.g: 'en-US'), it depends on your business requirements!
+ * (lang is simpler)
+ */
+export const fetchLocizeTranslations = async (): Promise => {
+ if (process.env.NODE_ENV !== 'development') {
+ logger.debug(`Pre-evaluation (prefetching of the static translations at build time) is starting.`);
+ const translationsByLocale: LocizeTranslationByLang = {};
+ const promises: Promise[] = [];
+
+ supportedLocales.map((supportedLocale: I18nLocale) => {
+ promises.push(fetchTranslations(supportedLocale?.lang));
+ });
+
+ // Run all promises in parallel and compute results into the dataset
+ const results: I18nextResources[] = await Promise.all(promises);
+ results.map((i18nextResources: I18nextResources, index) => translationsByLocale[supportedLocales[index]?.lang] = i18nextResources);
+
+ return translationsByLocale;
+ } else {
+ logger.debug(`Pre-evaluation (prefetching of the static translations at build time) is disabled in development mode for a better developer experience.`);
+ return {};
+ }
+};
+
+export default fetchLocizeTranslations;
diff --git a/src/modules/core/i18n/fetchStaticLocizeTranslations.preval.ts b/src/modules/core/i18n/fetchStaticLocizeTranslations.preval.ts
new file mode 100644
index 000000000..5c087a66a
--- /dev/null
+++ b/src/modules/core/i18n/fetchStaticLocizeTranslations.preval.ts
@@ -0,0 +1,73 @@
+import { supportedLocales } from '@/modules/core/i18n/i18nConfig';
+import {
+ fetchTranslations,
+ I18nextResources,
+} from '@/modules/core/i18n/i18nextLocize';
+import { I18nLocale } from '@/modules/core/i18n/types/I18nLocale';
+import { LocizeTranslationByLang } from '@/modules/core/i18n/types/LocizeTranslationByLang';
+import { createLogger } from '@/modules/core/logging/logger';
+import preval from 'next-plugin-preval';
+
+const fileLabel = 'modules/core/i18n/fetchStaticLocizeTranslations.preval';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Fetches the Locize API.
+ *
+ * Disabled during development, because it invokes too many API calls, the SSG pages are configured to fetch real-time data during development.
+ * See https://github.com/ricokahler/next-plugin-preval/issues/27
+ *
+ * XXX Must use default exports, otherwise it can cause issues - See https://github.com/ricokahler/next-plugin-preval/issues/19#issuecomment-848799473
+ *
+ * XXX We opinionately decided to use the "lang" (e.g: 'en') as Locize index, but it could also be the "name" (e.g: 'en-US'), it depends on your business requirements!
+ * (lang is simpler)
+ */
+export const fetchStaticLocizeTranslations = async (): Promise => {
+ if (process.env.NODE_ENV !== 'development') {
+ logger.debug(`Pre-evaluation (prefetching of the static translations at build time) is starting.`);
+ const translationsByLocale: LocizeTranslationByLang = {};
+ const promises: Promise[] = [];
+
+ supportedLocales.map((supportedLocale: I18nLocale) => {
+ promises.push(fetchTranslations(supportedLocale?.lang));
+ });
+
+ // Run all promises in parallel and compute results into the dataset
+ const results: I18nextResources[] = await Promise.all(promises);
+ results.map((i18nextResources: I18nextResources, index) => translationsByLocale[supportedLocales[index]?.lang] = i18nextResources);
+
+ return translationsByLocale;
+ } else {
+ logger.debug(`Pre-evaluation (prefetching of the static translations at build time) is disabled in development mode for a better developer experience.`);
+ return {};
+ }
+};
+
+/**
+ * Pre-fetches the Locize translations for all languages and stores the result in an cached internal JSON file.
+ * Overall, this approach allows us to have some static app-wide data that will never update, and have real-time data wherever we want.
+ *
+ * This is very useful to avoid fetching the same data for each page during the build step.
+ * By default, Next.js would call the Locize API once per page built.
+ * This was a huge pain for many reasons, because our app uses mostly static pages and we don't want those static pages to be updated.
+ *
+ * Also, even considering built time only, it was very inefficient, because Next was triggering too many API calls:
+ * - More than 40 fetch attempts (40+ demo pages)
+ * - Our in-memory cache was helping but wouldn't completely conceal the over-fetching caused by Next.js
+ * - Locize API has on-demand pricing, each call costs us money
+ *
+ * The shared/static dataset is accessible to:
+ * - All components
+ * - All pages (both getStaticProps and getStaticPaths, and even in getServerSideProps is you wish to!)
+ * - All API endpoints
+ *
+ * XXX The data are therefore STALE, they're not fetched in real-time.
+ * They won't update (the app won't display up-to-date data until the next deployment, for static pages).
+ *
+ * @example const allStaticLocizeTranslations = await getAllStaticLocizeTranslations();
+ *
+ * @see https://github.com/ricokahler/next-plugin-preval
+ */
+export default preval(fetchStaticLocizeTranslations());
diff --git a/src/modules/core/i18n/getLocizeTranslations.ts b/src/modules/core/i18n/getLocizeTranslations.ts
new file mode 100644
index 000000000..35b1b448a
--- /dev/null
+++ b/src/modules/core/i18n/getLocizeTranslations.ts
@@ -0,0 +1,55 @@
+import {
+ fetchTranslations,
+ I18nextResources,
+} from '@/modules/core/i18n/i18nextLocize';
+import { LocizeTranslationByLang } from '@/modules/core/i18n/types/LocizeTranslationByLang';
+import { createLogger } from '@/modules/core/logging/logger';
+
+const fileLabel = 'modules/core/i18n/getLocizeTranslations';
+const logger = createLogger({
+ fileLabel,
+});
+
+/**
+ * Returns all translations (indexed by language), based on the app-wide static/shared/stale data fetched at build time.
+ *
+ * @example const allStaticLocizeTranslations = await getAllStaticLocizeTranslations();
+ */
+export const getAllStaticLocizeTranslations = async (): Promise => {
+ return (await import('@/modules/core/i18n/fetchStaticLocizeTranslations.preval')) as unknown as LocizeTranslationByLang;
+};
+
+/**
+ * Returns all translations for one language, based on the app-wide static/shared/stale data fetched at build time.
+ *
+ * @example const i18nTranslations: I18nextResources = await getStaticLocizeTranslations(lang);
+ *
+ * @param lang
+ */
+export const getStaticLocizeTranslations = async (lang: string): Promise => {
+ const allStaticLocizeTranslations = await getAllStaticLocizeTranslations();
+ const i18nTranslations: I18nextResources = allStaticLocizeTranslations?.[lang];
+
+ if (!i18nTranslations) {
+ logger.warn(`Locize translations don't contain the translations for the lang "${lang}"`, allStaticLocizeTranslations);
+ }
+
+ return i18nTranslations || {};
+};
+
+/**
+ * Get the Locize translations by either resolving them from static cache, or fetching them in realtime.
+ *
+ * @param lang
+ * @param isPreviewMode
+ */
+export const getLocizeTranslations = async (lang: string, isPreviewMode = false): Promise => {
+ if (isPreviewMode || process.env.NODE_ENV === 'development') {
+ // When preview mode is enabled or working locally, we want to make real-time API requests to get up-to-date data
+ // Because using the "next-plugin-preval" plugin worsen developer experience in dev - See https://github.com/UnlyEd/next-right-now/discussions/335#discussioncomment-792821
+ return await fetchTranslations(lang);
+ } else {
+ // Otherwise, we fallback to the app-wide shared/static data (stale)
+ return await getStaticLocizeTranslations(lang);
+ }
+};
diff --git a/src/modules/core/i18n/hooks/useI18n.tsx b/src/modules/core/i18n/hooks/useI18n.tsx
new file mode 100644
index 000000000..9151b0d8b
--- /dev/null
+++ b/src/modules/core/i18n/hooks/useI18n.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import i18nContext, { I18nContext } from '../contexts/i18nContext';
+
+export type I18n = I18nContext
+
+/**
+ * Hook to access i18n/localisation data
+ *
+ * Uses i18nContext internally (provides an identical API)
+ *
+ * This hook should be used by components in favor of i18nContext directly,
+ * because it grants higher flexibility if you ever need to change the implementation (e.g: use something else than React.Context, like Redux/MobX/Recoil)
+ *
+ * @see https://slides.com/djanoskova/react-context-api-create-a-reusable-snackbar#/11
+ */
+const useI18n = (): I18n => {
+ return React.useContext(i18nContext);
+};
+
+export default useI18n;
diff --git a/src/modules/core/i18n/i18n.ts b/src/modules/core/i18n/i18n.ts
new file mode 100644
index 000000000..dada396e1
--- /dev/null
+++ b/src/modules/core/i18n/i18n.ts
@@ -0,0 +1,148 @@
+import { Cookies } from '@/modules/core/cookiesManager/types/Cookies';
+import { GenericObject } from '@/modules/core/data/types/GenericObject';
+import { createLogger } from '@/modules/core/logging/logger';
+import * as Sentry from '@sentry/node';
+import universalLanguageDetect from '@unly/universal-language-detector';
+import { ERROR_LEVELS } from '@unly/universal-language-detector/lib/utils/error';
+import { IncomingMessage } from 'http';
+import size from 'lodash.size';
+import { NextApiRequest } from 'next';
+import { ParsedUrlQuery } from 'querystring';
+import {
+ defaultLocale,
+ supportedLanguages,
+ supportedLocales,
+} from './i18nConfig';
+import { I18nLocale } from './types/I18nLocale';
+
+const fileLabel = 'modules/core/i18n/i18n';
+const logger = createLogger({
+ fileLabel,
+});
+
+export const LANG_EN = 'en';
+export const LANG_FR = 'fr';
+export const SUPPORTED_LOCALES: I18nLocale[] = supportedLocales;
+export const SUPPORTED_LANGUAGES: string[] = supportedLanguages;
+
+/**
+ * Language used by default if no user language can be resolved
+ * We use English because it's the most used languages among those supported
+ *
+ * @type {string}
+ */
+export const DEFAULT_LOCALE: string = defaultLocale;
+
+/**
+ * The fallback language is used when a translation is not found in the primary language
+ *
+ * Simple fallback language implementation.
+ * Only considers EN and FR languages.
+ *
+ * @param primaryLanguage
+ */
+export const resolveFallbackLanguage = (primaryLanguage: string): string => {
+ if (primaryLanguage === LANG_FR) {
+ return LANG_EN;
+ } else {
+ return LANG_FR;
+ }
+};
+
+/**
+ * Each customer may own its own variation of the translations.
+ * Resolves the lang of the customer variation, based on the lang and the customer.
+ *
+ * XXX To define a customer variation language, you must create it on Locize manually
+ * @example fr-x-customer1
+ * @example en-x-customer1
+ *
+ * @param lang
+ */
+export const resolveCustomerVariationLang = (lang: string): string => {
+ return `${lang}-x-${process.env.NEXT_PUBLIC_CUSTOMER_REF}`;
+};
+
+/**
+ * Resolves locale from query.
+ * Fallback to browser headers.
+ *
+ * Must only be used from "getServerSideProps".
+ *
+ * @param query
+ * @param req
+ * @param readonlyCookies
+ */
+export const resolveSSRLocale = (query: ParsedUrlQuery, req: IncomingMessage, readonlyCookies: Cookies): string => {
+ const hasLocaleFromUrl = !!query?.locale;
+
+ return hasLocaleFromUrl ? query?.locale as string : universalLanguageDetect({
+ supportedLanguages: SUPPORTED_LANGUAGES, // Whitelist of supported languages, will be used to filter out languages that aren't supported
+ fallbackLanguage: DEFAULT_LOCALE, // Fallback language in case the user's language cannot be resolved
+ acceptLanguageHeader: req?.headers?.['accept-language'], // Optional - Accept-language header will be used when resolving the language on the server side
+ serverCookies: readonlyCookies, // Optional - Cookie "i18next" takes precedence over navigator configuration (ex: "i18next: fr"), will only be used on the server side
+ errorHandler: (error: Error, level: ERROR_LEVELS, origin: string, context: GenericObject): void => {
+ Sentry.withScope((scope): void => {
+ scope.setExtra('level', level);
+ scope.setExtra('origin', origin);
+ scope.setContext('context', context);
+ Sentry.captureException(error);
+ });
+ logger.error(error.message);
+ },
+ });
+};
+
+/**
+ * Detects the browser locale (from "accept-language" header) and returns an array of locales by order of importance
+ *
+ * TODO Should be re-implemented using https://github.com/UnlyEd/universal-language-detector
+ * or https://www.npmjs.com/package/accept-language-parser or similar
+ * because current implementation is undecipherable and doesn't have any test
+ *
+ * @param req
+ * @see https://codesandbox.io/s/nextjs-i18n-staticprops-new-pbwjj?file=/src/static-translations/apiUtils/headerLanguage.js
+ */
+export const acceptLanguageHeaderLookup = (req: NextApiRequest): string[] | undefined => {
+ let found: string[];
+
+ if (typeof req !== 'undefined') {
+ const { headers } = req;
+ if (!headers) return found;
+
+ const locales = [];
+ const acceptLanguage = headers['accept-language'];
+
+ if (acceptLanguage) {
+ const lngs = [];
+ let i;
+ let m;
+ const rgx = /(([a-z]{2})-?([A-Z]{2})?)\s*;?\s*(q=([0-9.]+))?/gi;
+
+ do {
+ m = rgx.exec(acceptLanguage);
+ if (m) {
+ const lng = m[1];
+ const weight = m[5] || '1';
+ const q = Number(weight);
+ if (lng && !isNaN(q)) {
+ lngs.push({
+ lng,
+ q,
+ });
+ }
+ }
+ } while (m);
+
+ lngs.sort((a, b) => b.q - a.q);
+
+ for (i = 0; i < size(lngs); i++) {
+ locales.push(lngs[i].lng);
+ }
+
+ if (size(locales)) found = locales;
+ }
+ }
+
+ return found;
+};
diff --git a/src/modules/core/i18n/i18nConfig.js b/src/modules/core/i18n/i18nConfig.js
new file mode 100644
index 000000000..664b1b8c0
--- /dev/null
+++ b/src/modules/core/i18n/i18nConfig.js
@@ -0,0 +1,65 @@
+/*
+ XXX This file is loaded by "next.config.js" and cannot contain ES6+ code (import, etc.)
+ Note that any change should/must be followed by a server restart, because it's used in "next.config.js"
+ */
+
+/**
+ * Select the "supportedLocales.name" you want to use by default in your app.
+ * This value will be used as a fallback value, when the user locale cannot be resolved.
+ *
+ * @example en
+ * @example en-US
+ *
+ * @type {string}
+ */
+const defaultLocale = 'en';
+
+/**
+ * List of all supported locales by your app.
+ *
+ * If a user tries to load your site using non-supported locales, the default locale is used instead.
+ *
+ * @type {({name: string, lang: string}|{name: string, lang: string}|{name: string, lang: string})[]}
+ */
+const supportedLocales = [
+ {
+ name: 'fr',
+ lang: 'fr',
+ },
+ {
+ name: 'en-US',
+ lang: 'en',
+ },
+ {
+ name: 'en',
+ lang: 'en',
+ },
+];
+
+/**
+ * Returns the list of all supported languages.
+ * Basically extracts the "lang" parameter from the supported locales array.
+ *
+ * @type {string[]}
+ */
+const supportedLanguages = supportedLocales.map((item) => {
+ return item.lang;
+});
+
+/**
+ * Resolves the lang associated to a locale.
+ *
+ * @param localeToFind
+ * @return {string}
+ */
+const getLangFromLocale = (localeToFind) => {
+ return (supportedLocales.find((locale) => locale.name === localeToFind)).name;
+};
+
+// XXX Available through utils/i18n/i18n
+module.exports = {
+ defaultLocale,
+ supportedLocales,
+ supportedLanguages: [...new Set(supportedLanguages)], // Remove duplicates
+ getLangFromLocale,
+};
diff --git a/src/modules/core/i18n/i18nRouter.ts b/src/modules/core/i18n/i18nRouter.ts
new file mode 100644
index 000000000..1fdef46e1
--- /dev/null
+++ b/src/modules/core/i18n/i18nRouter.ts
@@ -0,0 +1,124 @@
+import { GenericObject } from '@/modules/core/data/types/GenericObject';
+import isEmpty from 'lodash.isempty';
+import { NextRouter } from 'next/router';
+import { removeTrailingSlash } from '../js/string';
+
+export type Route = {
+ locale: string;
+ href: string;
+ as?: string;
+}
+
+export type I18nRoute = {
+ i18nHref: string;
+ i18nAs: string;
+}
+
+/**
+ * Resolve the i18n route based on the route
+ *
+ * I18n route add the locale at the beginning of the route
+ *
+ * @example '/terms' => '/fr/terms'
+ *
+ * @param route
+ */
+export const resolveI18nRoute = (route: Route): I18nRoute => {
+ const {
+ locale,
+ href,
+ as,
+ } = route;
+ let i18nHref = href;
+ let i18nAs = as;
+
+ if (locale) {
+ i18nHref = removeTrailingSlash(`/[locale]${i18nHref}`);
+
+ // Apply default if "as" isn't specified (otherwise, keep provided value)
+ if (!as) {
+ i18nAs = removeTrailingSlash(`/${locale}${href}`);
+ }
+ }
+
+ return {
+ i18nAs,
+ i18nHref,
+ };
+};
+
+/**
+ * Resolve the home page based on the given locale
+ *
+ * @example 'fr-FR' => /fr-FR
+ *
+ * @param locale
+ */
+export const resolveI18nHomePage = (locale: string): I18nRoute => {
+ return {
+ i18nAs: '/[locale]',
+ i18nHref: `/${locale}`,
+ };
+};
+
+/**
+ * Resolves whether the "path" is the current active route
+ *
+ * Only consider the base path, will match nested paths if base matches
+ *
+ * @example isActive({pathname: '/[locale]'}, '') => true
+ * @example isActive({pathname: '/[locale]/terms'}, 'terms') => true
+ * @example isActive({pathname: '/[locale]/terms'}, '') => false
+ *
+ * @param router
+ * @param path
+ */
+export const isActive = (router: NextRouter, path: string): boolean => {
+ const route = router.pathname.replace('/[locale]', '');
+ const currentPaths = route.split('/');
+
+ return (currentPaths?.[1] || currentPaths?.[0]) === path;
+};
+
+export const stringifyQueryParameters = (router: NextRouter): string => {
+ const query: GenericObject = (router.query as GenericObject) || {};
+ delete query.locale; // Remove locale which is always included but doesn't interest us
+
+ if (!isEmpty(query)) {
+ return `?${new URLSearchParams(query)}`;
+ } else {
+ return '';
+ }
+};
+
+/**
+ * Returns the current page url, but for a different locale
+ * Includes query parameters (except "locale")
+ *
+ * @param locale
+ * @param router
+ */
+export const getSamePageI18nUrl = (locale: string, router: NextRouter): string => {
+ return `${router.pathname.replace('[locale]', locale)}${stringifyQueryParameters(router)}`;
+};
+
+/**
+ * Redirects the current page to the "same" page, but for a different locale
+ * Includes query parameters (except "locale")
+ *
+ * @param locale
+ * @param router
+ * @param forcePageReload Force full page reload (not just a client side transition)
+ * @see https://nextjs.org/docs/routing/imperatively Programmatic usage of Next Router
+ * @see https://nextjs.org/docs/api-reference/next/router#router-api Router API
+ */
+export const i18nRedirect = (locale: string, router: NextRouter, forcePageReload = false): void => {
+ const newUrl = getSamePageI18nUrl(locale, router);
+ const queryParameters: string = stringifyQueryParameters(router);
+
+ if (forcePageReload) {
+ location.href = newUrl;
+ } else {
+ router.push(router.pathname + queryParameters, newUrl);
+ }
+};
diff --git a/src/utils/i18nextLocize.ts b/src/modules/core/i18n/i18nextLocize.ts
similarity index 52%
rename from src/utils/i18nextLocize.ts
rename to src/modules/core/i18n/i18nextLocize.ts
index 739d977c1..8250b991b 100644
--- a/src/utils/i18nextLocize.ts
+++ b/src/modules/core/i18n/i18nextLocize.ts
@@ -1,22 +1,34 @@
+import { createLogger } from '@/modules/core/logging/logger';
import * as Sentry from '@sentry/node';
import { isBrowser } from '@unly/utils';
-import { createLogger } from '@unly/utils-simple-logger';
+import deepmerge from 'deepmerge';
import i18next, { i18n } from 'i18next';
-import fetch from 'isomorphic-unfetch';
+import i18nextLocizeBackend from 'i18next-locize-backend/cjs'; // https://github.com/locize/i18next-locize-backend/issues/323#issuecomment-619625571
import get from 'lodash.get';
import map from 'lodash.map';
+import size from 'lodash.size';
import { initReactI18next } from 'react-i18next';
+import {
+ resolveCustomerVariationLang,
+ resolveFallbackLanguage,
+} from './i18n';
-import { LANG_EN, LANG_FR } from './i18n';
-
+const fileLabel = 'modules/core/i18n/i18nextLocize';
const logger = createLogger({
- label: 'utils/i18nextLocize',
+ fileLabel,
});
+// On the client, we store the i18nextLocize instance in the following variable.
+// This prevents the client from reinitializing between page transitions, which caused infinite loop rendering.
+let globalI18nextInstance: i18n = null;
+
/**
* A resource locale can be either using a "flat" format or a "nested" format
*
- * @example
+ * XXX We strongly recommend to use either, but not both. Pick your choice at the beginning of the project and stick with it.
+ * We personally chose the "nested" format.
+ *
+ * @example Nested format
* {
* "login": {
* "label": "Log in",
@@ -24,14 +36,14 @@ const logger = createLogger({
* }
* }
*
- * @example
+ * @example Flat format
* {
* "login.label": "Log in",
* "login.user": "User Name",
* "User not found!": "User not found!"
* }
*/
-export declare type I18nextResourceLocale = {
+export type I18nextResourceLocale = {
[i18nKey: string]: string | I18nextResourceLocale; // The value can either be a string, or a nested object, itself containing either a string, or a nest object, etc.
}
@@ -48,15 +60,16 @@ export declare type I18nextResourceLocale = {
* }
* }
*/
-export declare type I18nextResources = {
+export type I18nextResources = {
[lang: string]: I18nextResourceLocale;
}
/**
* Memoized i18next resources are timestamped, to allow for cache invalidation strategies
* The timestamp's value is the time when the memoized cache was created
+ * It is used as "max-age", to discard/refresh cache when it's too old
*/
-export declare type MemoizedI18nextResources = {
+export type MemoizedI18nextResources = {
resources: I18nextResources;
ts: number; // Timestamp in milliseconds
}
@@ -78,18 +91,20 @@ const _memoizedI18nextResources: {
* Max age of the memoized cache (value in seconds)
*
* Once the cache is older than max age, it gets invalidated
- * This is used in both the browser and the server, but it's meant to be the most useful on the server
+ * This is used in both the browser and the server, but it's meant to be the most useful on the server (especially for SSR pages)
* - The browser will reset its own memoized cache as soon as the user refreshes the page (f5, etc.), so it's very not likely to ever reach max age
* - The server, on the other hand, may very much reach max age and needs to perform some cache invalidation from time to time,
* to make sure we use the latest translations
*
+ * For SSG pages, because Locize is not used at runtime, but only queried at build time, the whole caching process is not relevant and isn't really used
+ *
* @type {number} seconds
*/
const memoizedCacheMaxAge = ((): number => {
const oneHour = 3600;
const oneMinute = 60;
- if (process.env.APP_STAGE === 'production') {
+ if (process.env.NEXT_PUBLIC_APP_STAGE === 'production') {
// We want to cache for a while in production, to avoid unnecessary network calls
if (isBrowser()) {
// The in-memory browser cache will be invalidated when the page is refreshed,
@@ -120,7 +135,11 @@ const memoizedCacheMaxAge = ((): number => {
/**
* The default namespace name used to store all our translations
- * (Must be manually created in Locize's project before being used)
+ *
+ * We made the opinionated choice to only use one namespace for the whole app, because we don't see the need for a more complex setup at this point
+ * Feel free to change this behaviour and use several namespaces, if necessary
+ *
+ * XXX Must be manually created in Locize's project before being usable
*
* @type {string}
*/
@@ -135,10 +154,15 @@ const defaultNamespace = 'common';
* @see https://github.com/locize/locize-editor#initialize-with-optional-options
*/
export const locizeOptions = {
- projectId: process.env.LOCIZE_PROJECT_ID || undefined,
- apiKey: process.env.APP_STAGE === 'production' ? null : process.env.LOCIZE_API_KEY, // XXX Only define the API key on non-production environments (allows to use saveMissing from server)
- version: process.env.APP_STAGE === 'production' ? 'production' : 'latest', // XXX On production, use a dedicated production version
- referenceLng: 'fr',
+ projectId: process.env.NEXT_PUBLIC_LOCIZE_PROJECT_ID || undefined,
+ // XXX The API key must only be used on non-production environments (allows to use saveMissing features when working on the app)
+ // It must never be shared with the production environment, as it could incur costs and also could be used by attackers to make undesired changes to your app
+ // We strongly recommend to restrain the scope of your API keys so that those you use to work on the app locally cannot be used to change the production translation (in case they get leaked)
+ apiKey: process.env.NEXT_PUBLIC_APP_STAGE === 'production' ? null : process.env.LOCIZE_API_KEY,
+ // XXX On production, we use a dedicated production version
+ // You may want to use fixed versions instead (e.g: 1.0.0, etc.), it depends on your workflow really
+ version: process.env.NEXT_PUBLIC_APP_STAGE === 'production' ? 'production' : 'latest',
+ referenceLng: 'fr', // Language of reference, used to define the default translations
};
/**
@@ -152,9 +176,9 @@ export const locizeOptions = {
*/
export const locizeBackendOptions = {
...locizeOptions,
- // XXX "build" is meant to automatically invalidate the browser cache when releasing a different version,
+ // XXX The "build" parameter is meant to automatically invalidate the browser cache when releasing a different version,
// so that the end-users get the newest version immediately
- loadPath: `https://api.locize.app/{{projectId}}/{{version}}/{{lng}}/{{ns}}?build=${process.env.BUILD_TIMESTAMP}`,
+ loadPath: `https://api.locize.app/{{projectId}}/{{version}}/{{lng}}/{{ns}}?build=${process.env.NEXT_PUBLIC_APP_BUILD_TIMESTAMP}`,
private: false, // Should never be private
/**
@@ -168,12 +192,104 @@ export const locizeBackendOptions = {
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
allowedAddOrUpdateHosts: (host: string): boolean => {
- return process.env.APP_STAGE !== 'production'; // We allow any of our development or staging instance to update missing keys
+ return process.env.NEXT_PUBLIC_APP_STAGE !== 'production'; // We allow any of our development or staging instance to update missing keys
},
};
+const shouldLog = (): boolean => {
+ return process.env.NEXT_PUBLIC_APP_STAGE !== 'production' && process.env.NODE_ENV !== 'test' && process.env.STORYBOOK !== 'true';
+};
+
+/**
+ * Builds Locize API endpoint based on the desired lang and namespace
+ *
+ * @param lang
+ * @param namespace
+ */
+export const buildLocizeAPIEndpoint = (lang: string, namespace: string = defaultNamespace): string => {
+ return locizeBackendOptions
+ .loadPath
+ .replace('{{projectId}}', locizeBackendOptions.projectId)
+ .replace('{{version}}', locizeBackendOptions.version)
+ .replace('{{lng}}', lang)
+ .replace('{{ns}}', namespace);
+};
+
+/**
+ * Fetches the translations that are shared amongst all customers.
+ *
+ * Used as base translations, which can be overridden using translations variation.
+ *
+ * @param lang
+ */
+export const fetchBaseTranslations = async (lang: string): Promise => {
+ const locizeAPIEndpoint: string = buildLocizeAPIEndpoint(lang);
+ let i18nTranslations: I18nextResources = {};
+
+ try {
+ // Manually fetching locales from Locize API, for the "common" namespace of the current language
+ // XXX We fetch manually from Locize, because if we use the i18next "preload" feature, it'll crash with Next (no serverless support)
+ if (shouldLog()) {
+ logger.info(`Pre-fetching translations from ${locizeAPIEndpoint}`);
+ }
+ const defaultI18nTranslationsResponse: Response = await fetch(locizeAPIEndpoint);
+
+ try {
+ i18nTranslations = await defaultI18nTranslationsResponse.json();
+ } catch (e) {
+ if (shouldLog()) {
+ logger.error(e.message, `Failed to extract JSON data from locize API response for "${lang}"`);
+ }
+ Sentry.captureException(e);
+ }
+ } catch (e) {
+ if (shouldLog()) {
+ logger.error(e.message, `Failed to fetch data from locize API for "${lang}"`);
+ }
+ Sentry.captureException(e);
+ }
+
+ return i18nTranslations;
+};
+
+/**
+ * Fetches the translations that are specific to the customer (their own translations variation)
+ *
+ * @param lang
+ */
+export const fetchCustomerVariationTranslations = async (lang: string): Promise => {
+ const customerVariationLang = resolveCustomerVariationLang(lang);
+ const customerVariationLocizeAPIEndpoint: string = buildLocizeAPIEndpoint(customerVariationLang);
+ let customerVariationI18nTranslations: I18nextResources = {};
+
+ try {
+ // Manually fetching locales from Locize API, for the "common" namespace of the customer language variation
+ // XXX We fetch manually from Locize, because if we use the i18next "preload" feature, it'll crash with Next (no serverless support)
+ if (shouldLog()) {
+ logger.info(`Pre-fetching "${customerVariationLang}" translations variation from ${customerVariationLocizeAPIEndpoint}`);
+ }
+ const customerVariationI18nTranslationsResponse: Response = await fetch(customerVariationLocizeAPIEndpoint);
+
+ try {
+ customerVariationI18nTranslations = await customerVariationI18nTranslationsResponse.json();
+ } catch (e) {
+ if (shouldLog()) {
+ logger.error(e.message, `Failed to extract JSON data from locize API response for "${customerVariationLang}"`);
+ }
+ Sentry.captureException(e);
+ }
+ } catch (e) {
+ if (shouldLog()) {
+ logger.error(e.message, `Failed to fetch data from locize API for "${customerVariationLang}"`);
+ }
+ Sentry.captureException(e);
+ }
+
+ return customerVariationI18nTranslations;
+};
+
/**
- * Fetch translations from Locize API
+ * Fetch translations from Locize API (both base translations + translations that are specific to the customer)
*
* We use a "common" namespace that contains all our translations
* It's easier to manage, as we don't need to split translations under multiple files (we don't have so many translations)
@@ -182,16 +298,18 @@ export const locizeBackendOptions = {
* We don't use the Locize backend for fetching data, because it doesn't play well with Next framework (no serverless support)
* @see https://github.com/isaachinman/next-i18next/issues/274
*
- * Instead, we manually fetch (pre-fetch) the translations ourselves from the _app:getInitialProps, so that they're available both on the client and the server
+ * Instead, we manually fetch (pre-fetch) the translations ourselves from getServerSideProps/getStaticProps (or getInitialProps)
* @see https://github.com/i18next/i18next/issues/1373
*
- * XXX Make sure you don't use "auto" publish format, but either "nested" or "flat", to avoid the format to dynamically change (We recommend "nested")
+ * XXX Make sure you don't use "auto" publish format, but either "nested" or "flat", to avoid the format to dynamically change (We use "nested")
* @see https://faq.locize.com/#/general-questions/why-is-my-namespace-suddenly-a-flat-json
*
- * XXX Caching is quite complicated, because the caching strategy depends on the runtime engine (browser vs server) and the stage (development, staging, production)
- * For instance, we don't want to cache in development/staging, but we do for production in order to improve performances and decrease network usage
+ * XXX Caching is quite complicated, because the caching strategy depends on the runtime engine (browser vs server), the stage (development, staging, production) and the rendering mode (SSG, SSR)
+ * For instance, we don't want to cache in development/staging, but we do for production in order to improve performances and decrease network usage (when using SSR)
+ * When using SSG, caching doesn't matter because the pre-fetch is performed during the initial build (build time) and never at run time, so we don't need a caching mechanism
+ *
* Also, we want to invalidate the cache as soon as a new version is deployed, so that the end-users get the latest version immediately
- * (and avoid missing translations due to new content for which there wouldn't be any translation because we'd still be using a cached version)
+ * (and avoid missing translations because we'd be using a cached version)
* @see https://github.com/i18next/i18next/issues/1373
*
* Explanation about our cache implementation:
@@ -201,55 +319,50 @@ export const locizeBackendOptions = {
* - It's very useful in development/staging, where we don't have any browser caching enabled, because it'll reduce the API calls
* With this in-memory cache, the Locize API will be hit only once by the browser, and then the in-memory cache will take over when performing client-side navigation
* - The in-memory cache will be invalidated as soon as the page is refreshed (F5, cmd+r, etc.) and a new API call will be made
- * * Browser's native cache:
+ * - XXX Without in-memory cache, a new API call would be sent for each page transition, which is slower, costly and not necessary
+ * * Browser's local cache:
* - Development/staging stages:
- * - The browser's cache is completely disabled in development/staging, so that we may work/test with the latest translation
+ * - The browser's local cache is completely disabled in development/staging, so that we may work/test with the latest translation
* See the official recommendations https://docs.locize.com/more/caching
* - Production stage:
* - When in production, the browser will also cache for 1h, because we configured a "Cache-control: Max-Age" at https://www.locize.app/p/w7jrmdie/settings
* We followed the official recommendations https://docs.locize.com/more/caching
* 1h seems to be a good compromise between over-fetching (we don't expect our users to use the app for more than 1h straight)
* and applying translation "hot-fix" (worst case: "hot-fix"" be applied 1h later, which is acceptable)
- * - The browser's native cache will be invalidated as soon as a new release of the app is deployed,
+ * XXX SSG pages won't be affected by changes made in Locize once the app is built. A rebuild will be necessary to update the translations
+ * - The browser's local cache will be invalidated as soon as a new release of the app is deployed,
* because the value of "build" in the "loadPath" url will change and thus the browser will not use its cached version anymore
- * - Server:
+ * - Server (XXX this affects SSR pages in particular, as mentioned above):
* * In-memory: Once the translations are fetched from Locize, they're memoized
* - Development:
* - Once the translations are memoized, they'll be kept in-memory until the server restarts
- * Note that HMR will clear the cache, meaning any source code change that triggers a new build will thus invalidate the cache
+ * Note that full page reload may clear the cache if max-age is reached
* - Staging/production:
* - Once the translations are memoized, they'll be kept in-memory until... God knows what.
* - It's very hard to know how/when the memoized cache will be invalidated, as it's very much related to AWS Lambda lifecycle, which can be quite surprising
- * - It will likely be invalidated within 5-15mn if there aren't any request performed against the Lambda (nobody using the app)
+ * - It will likely be invalidated within 5-15mn if there aren't any request performed against the Lambda (when nobody is using the app)
* - But if there is a steady usage of the app then it could be memoized for hours/days
* - If there is a burst usage, then new lambdas will be triggered, with their own version of the memoized cache
* (thus, depending on which Lambda serves the requests, the result could be different if they don't use the same memoized cache)
* This could be the most surprising, as you don't know which Lambda will serve you, so you may see a different content by refreshing the page
- * (if browser cache is disabled, like when using the browser in Anonymous mode)
+ * (especially when browser cache is disabled, like when using the browser in Anonymous mode)
* - To sum up: The memoized cache will be invalidated when the server restarts
* - When a new release is deployed (this ensures that a new deployment invalidates the cache for all running Lambda instances at once)
* - When a Lambda that serves the app is started
- * - The cache is automatically invalidated after 1h (see memoizedCacheMaxAge). Thus ensuring that all Lambdas will be in-sync every hour or so,
+ * - The in-memory cache is automatically invalidated after 1h (see memoizedCacheMaxAge). Thus ensuring that all Lambdas will be in-sync every hour or so,
* even when hot-fixes are deployed on Locize
- * * Server's native cache:
+ * * Server's local cache:
* - Development:
- * - There is no known server-side caching abilities while in development, and we don't want to cache the content while in development
+ * - There is no known server-side caching abilities while in development, and we already rely on our in-memory cache while in development
* - Staging/production:
- * - The server doesn't have any native caching abilities. We're running under Next.js serverless mode (AWS Lambda), which has a /tmp directory where we could write
- * (See https://stackoverflow.com/questions/48364250/write-to-tmp-directory-in-aws-lambda-with-python)
- * But Next.js abstracts that away from us and we don't know how to write there. (would it be more efficient than the in-memory cache anyway?)
+ * - We could use on-disk caching mechanism during build, but it's not necessary because our in-memory cache is less complicated and doesn't require a writable file system
*
* @param {string} lang
* @return {Promise}
*/
export const fetchTranslations = async (lang: string): Promise => {
- const locizeAPIEndpoint: string = locizeBackendOptions
- .loadPath
- .replace('{{projectId}}', locizeBackendOptions.projectId)
- .replace('{{version}}', locizeBackendOptions.version)
- .replace('{{lng}}', lang)
- .replace('{{ns}}', defaultNamespace);
- const memoizedI18nextResources: MemoizedI18nextResources = get(_memoizedI18nextResources, locizeAPIEndpoint, null);
+ const cacheIndexKey: string = buildLocizeAPIEndpoint(lang); // Also used as cache index key (memoization)
+ const memoizedI18nextResources: MemoizedI18nextResources = get(_memoizedI18nextResources, cacheIndexKey, null);
if (memoizedI18nextResources) {
const date = new Date();
@@ -257,44 +370,31 @@ export const fetchTranslations = async (lang: string): Promise
// If the memoized cache isn't too old, use it
if (+date - memoizedI18nextResources.ts < 1000 * memoizedCacheMaxAge) {
// If the i18next resources have been fetched previously, they're therefore available in the memory and we return them untouched to avoid network calls
- logger.info('Translations were resolved from in-memory cache');
+ if (shouldLog()) {
+ logger.info('Translations were resolved from in-memory cache');
+ }
return (memoizedI18nextResources).resources;
} else {
// Memoized cache is too old, we need to fetch from Locize API again
- logger.info('Translations from in-memory cache are too old (> max age) and thus have been invalidated');
- }
- }
- let commonLocales = {};
-
- try {
- // Fetching locales for i18next, for the "common" namespace
- // XXX We do that because if we don't, then the SSR fails at fetching those locales using the i18next "backend" and renders too early
- // This hack helps fix the SSR issue
- // On the other hand, it seems that once the i18next "resources" are set, they don't change,
- // so this workaround could cause sync issue if we were using multiple namespaces, but we aren't and probably won't
- logger.info(`Pre-fetching translations from ${locizeAPIEndpoint}`);
- const defaultLocalesResponse = await fetch(locizeAPIEndpoint);
-
- try {
- commonLocales = await defaultLocalesResponse.json();
- } catch (e) {
- // TODO Load the locales from local JSON files if ever the API fails, to still display i18n translation even if it's not the most up-to-date?
- logger.error(e.message, 'Failed to extract JSON data from locize API response');
- Sentry.captureException(e);
+ if (shouldLog()) {
+ logger.info(`Translations from in-memory cache are too old (> ${memoizedCacheMaxAge} seconds) and thus have been invalidated`);
+ }
}
- } catch (e) {
- logger.error(e.message, 'Failed to fetch data from locize API');
- Sentry.captureException(e);
}
+ const i18nBaseTranslations: I18nextResources = await fetchBaseTranslations(lang);
+ const customerVariationI18nTranslations: I18nextResources = await fetchCustomerVariationTranslations(lang);
+ const i18nTranslations: I18nextResources = deepmerge(i18nBaseTranslations, customerVariationI18nTranslations);
const i18nextResources: I18nextResources = {
[lang]: {
- [defaultNamespace]: commonLocales,
+ [defaultNamespace]: i18nTranslations,
},
};
- logger.info('Translations were resolved from Locize API and are now being memoized for subsequent calls');
+ if (shouldLog()) {
+ logger.info('Translations were resolved from Locize API and are now being memoized for subsequent calls');
+ }
- _memoizedI18nextResources[locizeAPIEndpoint] = {
+ _memoizedI18nextResources[cacheIndexKey] = {
resources: i18nextResources,
ts: ((): number => {
const date = new Date();
@@ -311,72 +411,76 @@ export const fetchTranslations = async (lang: string): Promise
/**
* Configure i18next with Locize backend.
*
- * - Initialized with pre-defined "lang" (to make sure GraphCMS and Locize are configured with the same language)
- * - Initialized with pre-fetched "defaultLocales" (for SSR compatibility)
- * - Fetches translations from Locize backend
- * - Automates the creation of missing translations using "saveMissing: true"
- * - Display Locize "in-context" Editor when appending "/?locize=true" to the url (e.g http://localhost:8888/?locize=true)
- * - Automatically "touches" translations so it's easier to know when they've been used for the last time,
+ * - Initialized with pre-defined "lang"
+ * - Initialized with pre-fetched "i18nTranslations"
+ * - Automates the creation of missing translations using "saveMissing: true", when working locally
+ * - Display Locize "in-context" Editor when appending "/?locize=true" to the url (e.g http://localhost:8888/?locize=true) in non-production stages
+ * - Automatically "touches" translations so it's easier to know when they've been used for the last time (when working locally),
* thus helping translators figure out which translations are not used anymore so they can delete them
*
- * XXX We don't rely on https://github.com/i18next/i18next-browser-languageDetector because we have our own way of resolving the language to use, using utils/locale
+ * XXX We don't rely on https://github.com/i18next/i18next-browser-languageDetector because we have our own way of resolving the language to use (in getStaticProps/getServerSideProps)
*
* @param lang
- * @param defaultLocales
+ * @param i18nTranslations
*/
-const i18nextLocize = (lang: string, defaultLocales: I18nextResources): i18n => {
- // If LOCIZE_PROJECT_ID is not defined then we mustn't init i18next or it'll crash the whole app when running in non-production stage
+const createI18nextLocizeInstance = (lang: string, i18nTranslations: I18nextResources): i18n => {
+ // If NEXT_PUBLIC_LOCIZE_PROJECT_ID is not defined then we mustn't init i18next or it'll crash the whole app when running in non-production stage
// In that case, better crash early with an explicit message
- if (!process.env.LOCIZE_PROJECT_ID) {
- throw new Error('Env var "LOCIZE_PROJECT_ID" is not defined. Please add it to you .env.build file (development) or now*.json (staging/production)');
+ if (!process.env.NEXT_PUBLIC_LOCIZE_PROJECT_ID) {
+ throw new Error('Env var "NEXT_PUBLIC_LOCIZE_PROJECT_ID" is not defined. Please add it to you .env.build file (development) or vercel*.json (staging/production)');
}
// Plugins will be dynamically added at runtime, depending on the runtime engine (node or browser)
const plugins = [ // XXX Only plugins that are common to all runtimes should be defined by default
initReactI18next, // passes i18next down to react-i18next
+ i18nextLocizeBackend, // loads translations, saves new keys to it (when saveMissing: true) - https://github.com/locize/i18next-locize-backend
];
+ if (shouldLog()) {
+ logger.info(`Using "react-i18next" plugin`);
+ logger.info(`Using "i18next-locize-backend" plugin`);
+ }
// Dynamically load different modules depending on whether we're running node or browser engine
if (!isBrowser()) {
// XXX Use "__non_webpack_require__" on the server
- // loads translations, saves new keys to it (saveMissing: true)
- // https://github.com/locize/i18next-node-locize-backend
- const i18nextNodeLocizeBackend = __non_webpack_require__('i18next-node-locize-backend');
- plugins.push(i18nextNodeLocizeBackend);
-
- // sets a timestamp of last access on every translation segment on locize
- // -> safely remove the ones not being touched for weeks/months
+ // Sets a timestamp of last access on every translation segment on locize
+ // -> Helps to safely remove the ones not being touched for weeks/months
+ // N.B: It doesn't delete anything on its own, it just a helper to help you know when a translation was last used
+ // XXX We only enable this server side because it's only used in development and there is no point increasing the browser bundle size
// https://github.com/locize/locize-node-lastused
- const locizeNodeLastUsed = __non_webpack_require__('locize-node-lastused');
+ const locizeNodeLastUsed = __non_webpack_require__('locize-lastused/cjs');
plugins.push(locizeNodeLastUsed);
+ if (shouldLog()) {
+ logger.info(`Using "locize-lastused" plugin`);
+ }
} else {
// XXX Use "require" on the browser, always take the "default" export specifically
- // loads translations, saves new keys to it (saveMissing: true)
- // https://github.com/locize/i18next-locize-backend
- // eslint-disable-next-line @typescript-eslint/no-var-requires
- const i18nextLocizeBackend = require('i18next-locize-backend').default;
- plugins.push(i18nextLocizeBackend);
-
// InContext Editor of locize ?locize=true to show it
// https://github.com/locize/locize-editor
// eslint-disable-next-line @typescript-eslint/no-var-requires
const locizeEditor = require('locize-editor').default;
plugins.push(locizeEditor);
+ if (shouldLog()) {
+ logger.info(`Using "locize-editor" plugin`);
+ }
}
const i18nInstance = i18next;
+ if (shouldLog()) {
+ logger.info(`Using ${size(plugins)} plugins in total`);
+ }
map(plugins, (plugin) => i18nInstance.use(plugin));
// @ts-ignore
i18nInstance.init({ // XXX See https://www.i18next.com/overview/configuration-options
- resources: defaultLocales,
+ resources: i18nTranslations,
// preload: ['fr', 'en'], // XXX Supposed to preload languages, doesn't work with Next
- cleanCode: true, // language will be lowercased EN --> en while leaving full locales like en-US
- debug: process.env.APP_STAGE === 'development', // Only enable locally, too much noise otherwise
- saveMissing: process.env.APP_STAGE === 'development', // Only save missing translations on development environment, to avoid outdated keys to be created from older staging deployments
+ cleanCode: true, // language will be lowercased 'EN' --> 'en' while leaving full locales like 'en-US'
+ debug: process.env.NEXT_PUBLIC_APP_STAGE !== 'production' && isBrowser() && process.env.NODE_ENV !== 'test' && process.env.STORYBOOK !== 'true', // Only enable on non-production stages and only on browser (too much noise on server) and not when used through Storybook XXX Note that missing keys will be created on the server first, so you should enable server logs if you need to debug "saveMissing" feature
+ saveMissing: process.env.NEXT_PUBLIC_APP_STAGE === 'development', // Only save missing translations on development environment, to avoid outdated keys to be created from older staging deployments
saveMissingTo: defaultNamespace,
- lng: lang, // XXX We don't use the built-in i18next-browser-languageDetector because we have our own way of detecting language, which must behave identically for both GraphCMS I18n and react-I18n
- fallbackLng: lang === LANG_FR ? LANG_EN : LANG_FR,
+ lng: lang, // XXX We don't use the built-in i18next-browser-languageDetector because we have our own way of detecting language
+ fallbackLng: resolveFallbackLanguage(lang),
ns: [defaultNamespace], // string or array of namespaces to load
defaultNS: defaultNamespace, // default namespace used if not passed to translation function
interpolation: {
@@ -404,5 +508,25 @@ const i18nextLocize = (lang: string, defaultLocales: I18nextResources): i18n =>
return i18nInstance;
};
+/**
+ * Singleton helper
+ *
+ * Return the global globalI18nextInstance if set, or initialize it, if not.
+ * This prevents the client from reinitializing between page transitions, which caused infinite loop rendering.
+ *
+ * @param lang
+ * @param i18nTranslations
+ */
+const i18nextLocize = (lang: string, i18nTranslations: I18nextResources): i18n => {
+ // If the singleton isn't init yet, or if the requested language is different from the singleton, then we create a new instance
+ if (!globalI18nextInstance || lang !== get(globalI18nextInstance, 'language', lang)) {
+ globalI18nextInstance = createI18nextLocizeInstance(lang, i18nTranslations);
+
+ return globalI18nextInstance;
+ } else {
+ return globalI18nextInstance;
+ }
+};
+
export default i18nextLocize;
diff --git a/src/modules/core/i18n/middlewares/localeMiddleware.ts b/src/modules/core/i18n/middlewares/localeMiddleware.ts
new file mode 100644
index 000000000..eeda88e80
--- /dev/null
+++ b/src/modules/core/i18n/middlewares/localeMiddleware.ts
@@ -0,0 +1,90 @@
+import { logEvent } from '@/modules/core/amplitude/amplitudeServerClient';
+import { AMPLITUDE_EVENTS } from '@/modules/core/amplitude/events';
+import { Customer } from '@/modules/core/data/types/Customer';
+import { GraphCMSDataset } from '@/modules/core/data/types/GraphCMSDataset';
+import { getGraphcmsDataset } from '@/modules/core/gql/getGraphcmsDataset';
+import { prepareGraphCMSLocaleHeader } from '@/modules/core/gql/graphcms';
+import {
+ StaticCustomer,
+ StaticDataset,
+} from '@/modules/core/gql/types/StaticDataset';
+import { createLogger } from '@/modules/core/logging/logger';
+import redirect from '@/utils/redirect';
+import includes from 'lodash.includes';
+import size from 'lodash.size';
+import {
+ NextApiRequest,
+ NextApiResponse,
+} from 'next';
+import {
+ acceptLanguageHeaderLookup,
+ DEFAULT_LOCALE,
+ resolveFallbackLanguage,
+} from '../i18n';
+
+const fileLabel = 'modules/core/i18n/localeMiddleware';
+const logger = createLogger({ // eslint-disable-line no-unused-vars,@typescript-eslint/no-unused-vars
+ fileLabel,
+});
+
+/**
+ * Detects the browser locale and redirects to the requested page.
+ * Only used when the locale isn't specified in the url (called through /api/autoRedirectToLocalisedPage).
+ *
+ * @example / => /fr
+ * @example /terms => /fr/terms
+ *
+ * @param req
+ * @param res
+ */
+export const localeMiddleware = async (req: NextApiRequest, res: NextApiResponse): Promise => {
+ logger.debug('Detecting browser locale...');
+ const detections: string[] = acceptLanguageHeaderLookup(req) || [];
+ const lang = DEFAULT_LOCALE;
+ const bestCountryCodes: string[] = [lang, resolveFallbackLanguage(lang)];
+ const gcmsLocales: string = prepareGraphCMSLocaleHeader(bestCountryCodes);
+ const dataset: StaticDataset | GraphCMSDataset = await getGraphcmsDataset(gcmsLocales);
+ const customer: StaticCustomer | Customer = dataset?.customer;
+ let localeFound; // Will contain the most preferred browser locale (e.g: fr-FR, fr, en-US, en, etc.)
+
+ if (detections && !!size(detections)) {
+ detections.forEach((language) => {
+ if (localeFound || typeof language !== 'string') return;
+
+ if (includes(customer?.availableLanguages, language)) {
+ localeFound = language;
+ }
+ });
+
+ logger.debug(`Locale resolved using browser headers: "${localeFound}", with browser locales: [${detections.join(', ')}]`);
+ } else {
+ logger.debug(`Couldn't detect any locales in "accept-language" header. (This will happens with robots, e.g: Cypress E2E)`);
+ }
+
+ if (!localeFound) {
+ localeFound = customer?.availableLanguages?.[0] || DEFAULT_LOCALE;
+ }
+
+ await logEvent(AMPLITUDE_EVENTS.API_LOCALE_MIDDLEWARE_INVOKED, null, {
+ locale: localeFound,
+ });
+
+ logger.debug(`Locale applied: "${localeFound}", for url "${req.url}"`);
+
+ let redirectTo: string;
+
+ if (req.url === '/' || req.url === '/api/autoRedirectToLocalisedPage') {
+ redirectTo = `/${localeFound}`;
+ } else if (req.url.charAt(0) === '/' && req.url.charAt(1) === '?') {
+ // XXX Other routes (e.g: "/fr/terms?utm=source", "/terms?utm=source") are properly handled (they don't need custom routing/rewrites)
+ redirectTo = `/${localeFound}${req.url.slice(1)}`;
+ } else {
+ redirectTo = `/${localeFound}${req.url}`;
+ }
+
+ logger.debug(`Redirecting to "${redirectTo}" ...`);
+
+ return redirect(res, redirectTo);
+};
+
+export default localeMiddleware;
diff --git a/src/modules/core/i18n/types/I18nLocale.ts b/src/modules/core/i18n/types/I18nLocale.ts
new file mode 100644
index 000000000..011e4683f
--- /dev/null
+++ b/src/modules/core/i18n/types/I18nLocale.ts
@@ -0,0 +1,19 @@
+/**
+ * Localisation naming is quite tricky. Some people refer to "locale" as "language", other as "country/regional locale", other need additional variants.
+ *
+ * A locale is composed of:
+ * - A language code
+ * - Country/Region code (optional)
+ * - Variant code (optional)
+ *
+ * We tried to keep it simple by using a "name", which represents whatever you want to represent.
+ * In our case, we allow/use both language-based (i.e: fr) and country-based (i.e: fr-FR)
+ * We believe it's the most common way to get started with localisation
+ * (and if you just need the language, you can just remove country-based locales in ../i18nConfig.js)
+ *
+ * @see i18nConfig.js Application locales configuration
+ */
+export type I18nLocale = {
+ lang: string; // Locale language (e.g: fr)
+ name: string; // Locale name (e.g: fr-FR)
+}
diff --git a/src/modules/core/i18n/types/LocizeTranslationByLang.ts b/src/modules/core/i18n/types/LocizeTranslationByLang.ts
new file mode 100644
index 000000000..2bd72dca8
--- /dev/null
+++ b/src/modules/core/i18n/types/LocizeTranslationByLang.ts
@@ -0,0 +1,5 @@
+import { I18nextResources } from '@/modules/core/i18n/i18nextLocize';
+
+export type LocizeTranslationByLang = {
+ [lang: string]: I18nextResources;
+}
diff --git a/src/utils/array.test.ts b/src/modules/core/js/array.test.ts
similarity index 89%
rename from src/utils/array.test.ts
rename to src/modules/core/js/array.test.ts
index 1564166de..760bdf465 100644
--- a/src/utils/array.test.ts
+++ b/src/modules/core/js/array.test.ts
@@ -1,6 +1,13 @@
-import { findNextItem, findPreviousItem } from './array';
+import {
+ findNextItem,
+ findPreviousItem,
+} from './array';
-describe(`utils/array.ts`, () => {
+/**
+ * @group unit
+ * @group utils
+ */
+describe(`utils/js/array.ts`, () => {
const item1 = { a: 1 };
const item2 = { a: 2 };
const item3 = { a: 3 };
diff --git a/src/utils/array.ts b/src/modules/core/js/array.ts
similarity index 73%
rename from src/utils/array.ts
rename to src/modules/core/js/array.ts
index 95a378806..7d4041276 100644
--- a/src/utils/array.ts
+++ b/src/modules/core/js/array.ts
@@ -1,4 +1,6 @@
+import findIndex from 'lodash.findindex';
import isArray from 'lodash.isarray';
+import size from 'lodash.size';
/**
* Finds the previous item of the current item, in an array of items.
@@ -12,11 +14,11 @@ export const findPreviousItem =