diff --git a/packages/commerce-sdk-react/src/auth/index.ts b/packages/commerce-sdk-react/src/auth/index.ts
index a37229c58e..1428f7055d 100644
--- a/packages/commerce-sdk-react/src/auth/index.ts
+++ b/packages/commerce-sdk-react/src/auth/index.ts
@@ -1128,7 +1128,7 @@ class Auth {
*/
async authorizePasswordless(parameters: AuthorizePasswordlessParams) {
const userid = parameters.userid
- const callbackURI = this.passwordlessLoginCallbackURI
+ const callbackURI = parameters.callbackURI || this.passwordlessLoginCallbackURI
const usid = this.get('usid')
const mode = callbackURI ? 'callback' : 'sms'
diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.js
index 4d09cf4946..77a9ccc569 100644
--- a/packages/template-retail-react-app/app/hooks/use-auth-modal.js
+++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.js
@@ -44,6 +44,8 @@ import {usePrevious} from '@salesforce/retail-react-app/app/hooks/use-previous'
import {usePasswordReset} from '@salesforce/retail-react-app/app/hooks/use-password-reset'
import {isServer} from '@salesforce/retail-react-app/app/utils/utils'
import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
+import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'
+import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
export const LOGIN_VIEW = 'login'
export const REGISTER_VIEW = 'register'
@@ -84,11 +86,16 @@ export const AuthModal = ({
const toast = useToast()
const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C)
const register = useAuthHelper(AuthHelpers.Register)
+ const appOrigin = useAppOrigin()
const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD)
const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState(initialEmail)
const {getPasswordResetToken} = usePasswordReset()
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
+ const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI
+ const callbackURL = isAbsoluteURL(passwordlessConfigCallback)
+ ? passwordlessConfigCallback
+ : `${appOrigin}${passwordlessConfigCallback}`
const {data: baskets} = useCustomerBaskets(
{parameters: {customerId}},
@@ -105,7 +112,11 @@ export const AuthModal = ({
const handlePasswordlessLogin = async (email) => {
try {
- await authorizePasswordlessLogin.mutateAsync({userid: email})
+ const redirectPath = window.location.pathname + window.location.search
+ await authorizePasswordlessLogin.mutateAsync({
+ userid: email,
+ callbackURI: `${callbackURL}?redirectUrl=${redirectPath}`
+ })
setCurrentView(EMAIL_VIEW)
} catch (error) {
const message = USER_NOT_FOUND_ERROR.test(error.message)
diff --git a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js
index a13580c2d4..8c5a8775e4 100644
--- a/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js
+++ b/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js
@@ -19,6 +19,7 @@ import Account from '@salesforce/retail-react-app/app/pages/account'
import {rest} from 'msw'
import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data'
import * as ReactHookForm from 'react-hook-form'
+import {AuthHelpers} from '@salesforce/commerce-sdk-react'
jest.setTimeout(60000)
@@ -47,6 +48,21 @@ const mockRegisteredCustomer = {
login: 'customer@test.com'
}
+const mockAuthHelperFunctions = {
+ [AuthHelpers.AuthorizePasswordless]: {mutateAsync: jest.fn()},
+ [AuthHelpers.Register]: {mutateAsync: jest.fn()}
+}
+
+jest.mock('@salesforce/commerce-sdk-react', () => {
+ const originalModule = jest.requireActual('@salesforce/commerce-sdk-react')
+ return {
+ ...originalModule,
+ useAuthHelper: jest
+ .fn()
+ .mockImplementation((helperType) => mockAuthHelperFunctions[helperType])
+ }
+})
+
let authModal = undefined
const MockedComponent = (props) => {
const {initialView, isPasswordlessEnabled = false} = props
@@ -155,17 +171,63 @@ test('Renders check email modal on email mode', async () => {
mockUseForm.mockRestore()
})
-test('Renders passwordless login when enabled', async () => {
- const user = userEvent.setup()
+describe('Passwordless enabled', () => {
+ test('Renders passwordless login when enabled', async () => {
+ const user = userEvent.setup()
- renderWithProviders()
+ renderWithProviders()
- // open the modal
- const trigger = screen.getByText(/open modal/i)
- await user.click(trigger)
+ // open the modal
+ const trigger = screen.getByText(/open modal/i)
+ await user.click(trigger)
- await waitFor(() => {
- expect(screen.getByText(/continue securely/i)).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByText(/continue securely/i)).toBeInTheDocument()
+ })
+ })
+
+ test('Allows passwordless login', async () => {
+ const {user} = renderWithProviders()
+ const validEmail = 'test@salesforce.com'
+
+ // open the modal
+ const trigger = screen.getByText(/open modal/i)
+ await user.click(trigger)
+
+ await waitFor(() => {
+ expect(screen.getByText(/continue securely/i)).toBeInTheDocument()
+ })
+
+ // enter a valid email address
+ await user.type(screen.getByLabelText('Email'), validEmail)
+
+ // initiate passwordless login
+ const passwordlessLoginButton = screen.getByText(/continue securely/i)
+ // Click the button twice as the isPasswordlessLoginClicked state doesn't change after the first click
+ await user.click(passwordlessLoginButton)
+ await user.click(passwordlessLoginButton)
+ expect(
+ mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync
+ ).toHaveBeenCalledWith({
+ userid: validEmail,
+ callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/'
+ })
+
+ // check that check email modal is open
+ await waitFor(() => {
+ const withinForm = within(screen.getByTestId('sf-form-resend-passwordless-email'))
+ expect(withinForm.getByText(/Check Your Email/i)).toBeInTheDocument()
+ expect(withinForm.getByText(validEmail)).toBeInTheDocument()
+ })
+
+ // resend the email
+ user.click(screen.getByText(/Resend Link/i))
+ expect(
+ mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync
+ ).toHaveBeenCalledWith({
+ userid: validEmail,
+ callbackURI: 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/'
+ })
})
})
diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx
index 749542293c..ef0cdc8fd1 100644
--- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx
+++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx
@@ -41,7 +41,10 @@ import {
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
import {useCurrentCustomer} from '@salesforce/retail-react-app/app/hooks/use-current-customer'
import {useCurrentBasket} from '@salesforce/retail-react-app/app/hooks/use-current-basket'
+import {isAbsoluteURL} from '@salesforce/retail-react-app/app/page-designer/utils'
+import {useAppOrigin} from '@salesforce/retail-react-app/app/hooks/use-app-origin'
import {AuthHelpers, useAuthHelper, useShopperBasketsMutation} from '@salesforce/commerce-sdk-react'
+import {getConfig} from '@salesforce/pwa-kit-runtime/utils/ssr-config'
import {
API_ERROR_MESSAGE,
FEATURE_UNAVAILABLE_ERROR_MESSAGE,
@@ -55,6 +58,7 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id
const navigate = useNavigation()
const {data: customer} = useCurrentCustomer()
const {data: basket} = useCurrentBasket()
+ const appOrigin = useAppOrigin()
const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C)
const logout = useAuthHelper(AuthHelpers.Logout)
const authorizePasswordlessLogin = useAuthHelper(AuthHelpers.AuthorizePasswordless)
@@ -77,10 +81,18 @@ const ContactInfo = ({isSocialEnabled = false, isPasswordlessEnabled = false, id
const [authModalView, setAuthModalView] = useState(PASSWORD_VIEW)
const authModal = useAuthModal(authModalView)
const [isPasswordlessLoginClicked, setIsPasswordlessLoginClicked] = useState(false)
+ const passwordlessConfigCallback = getConfig().app.login?.passwordless?.callbackURI
+ const callbackURL = isAbsoluteURL(passwordlessConfigCallback)
+ ? passwordlessConfigCallback
+ : `${appOrigin}${passwordlessConfigCallback}`
const handlePasswordlessLogin = async (email) => {
try {
- await authorizePasswordlessLogin.mutateAsync({userid: email})
+ const redirectPath = window.location.pathname + window.location.search
+ await authorizePasswordlessLogin.mutateAsync({
+ userid: email,
+ callbackURI: `${callbackURL}?redirectUrl=${redirectPath}`
+ })
setAuthModalView(EMAIL_VIEW)
authModal.onOpen()
} catch (error) {
diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js
index aac8c5933d..34333b73b5 100644
--- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js
+++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.test.js
@@ -105,6 +105,7 @@ describe('passwordless and social disabled', () => {
describe('passwordless enabled', () => {
let currentBasket = JSON.parse(JSON.stringify(scapiBasketWithItem))
+
beforeEach(() => {
global.server.use(
rest.put('*/baskets/:basketId/customer', (req, res, ctx) => {
@@ -147,6 +148,9 @@ describe('passwordless enabled', () => {
})
test('allows passwordless login', async () => {
+ jest.spyOn(window, 'location', 'get').mockReturnValue({
+ pathname: '/checkout'
+ })
const {user} = renderWithProviders()
// enter a valid email address
@@ -159,7 +163,11 @@ describe('passwordless enabled', () => {
await user.click(passwordlessLoginButton)
expect(
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync
- ).toHaveBeenCalledWith({userid: validEmail})
+ ).toHaveBeenCalledWith({
+ userid: validEmail,
+ callbackURI:
+ 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/checkout'
+ })
// check that check email modal is open
await waitFor(() => {
@@ -172,7 +180,11 @@ describe('passwordless enabled', () => {
user.click(screen.getByText(/Resend Link/i))
expect(
mockAuthHelperFunctions[AuthHelpers.AuthorizePasswordless].mutateAsync
- ).toHaveBeenCalledWith({userid: validEmail})
+ ).toHaveBeenCalledWith({
+ userid: validEmail,
+ callbackURI:
+ 'https://webhook.site/27761b71-50c1-4097-a600-21a3b89a546c?redirectUrl=/checkout'
+ })
})
test('allows login using password', async () => {
diff --git a/packages/template-retail-react-app/app/pages/login/index.jsx b/packages/template-retail-react-app/app/pages/login/index.jsx
index f32d27247a..e2c3b4a103 100644
--- a/packages/template-retail-react-app/app/pages/login/index.jsx
+++ b/packages/template-retail-react-app/app/pages/login/index.jsx
@@ -75,6 +75,7 @@ const Login = ({initialView = LOGIN_VIEW}) => {
const [currentView, setCurrentView] = useState(initialView)
const [passwordlessLoginEmail, setPasswordlessLoginEmail] = useState('')
const [loginType, setLoginType] = useState(LOGIN_TYPES.PASSWORD)
+ const [redirectPath, setRedirectPath] = useState('')
const handleMergeBasket = () => {
const hasBasketItem = baskets?.baskets?.[0]?.productItems?.length > 0
@@ -149,7 +150,12 @@ const Login = ({initialView = LOGIN_VIEW}) => {
// customer baskets to be loaded to guarantee proper basket merging.
useEffect(() => {
if (path === PASSWORDLESS_LOGIN_LANDING_PATH && isSuccessCustomerBaskets) {
- const token = queryParams.get('token')
+ const token = decodeURIComponent(queryParams.get('token'))
+ if (queryParams.get('redirect_url')) {
+ setRedirectPath(decodeURIComponent(queryParams.get('redirect_url')))
+ } else {
+ setRedirectPath('')
+ }
const passwordlessLogin = async () => {
try {
@@ -170,13 +176,10 @@ const Login = ({initialView = LOGIN_VIEW}) => {
useEffect(() => {
if (isRegistered) {
handleMergeBasket()
- if (location?.state?.directedFrom) {
- navigate(location.state.directedFrom)
- } else {
- navigate('/account')
- }
+ const redirectTo = redirectPath ? redirectPath : '/account'
+ navigate(redirectTo)
}
- }, [isRegistered])
+ }, [isRegistered, redirectPath])
/**************** Einstein ****************/
useEffect(() => {
diff --git a/packages/template-retail-react-app/app/pages/login/index.test.js b/packages/template-retail-react-app/app/pages/login/index.test.js
index 66268ef08d..8a5ea7e269 100644
--- a/packages/template-retail-react-app/app/pages/login/index.test.js
+++ b/packages/template-retail-react-app/app/pages/login/index.test.js
@@ -19,6 +19,7 @@ import Registration from '@salesforce/retail-react-app/app/pages/registration'
import ResetPassword from '@salesforce/retail-react-app/app/pages/reset-password'
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data'
+
const mockMergedBasket = {
basketId: 'a10ff320829cb0eef93ca5310a',
currency: 'USD',
@@ -97,6 +98,7 @@ describe('Logging in tests', function () {
})
)
})
+
test('Allows customer to sign in to their account', async () => {
const {user} = renderWithProviders(, {
wrapperProps: {
diff --git a/packages/template-retail-react-app/app/pages/login/passwordless-landing.test.js b/packages/template-retail-react-app/app/pages/login/passwordless-landing.test.js
new file mode 100644
index 0000000000..34867fafa3
--- /dev/null
+++ b/packages/template-retail-react-app/app/pages/login/passwordless-landing.test.js
@@ -0,0 +1,154 @@
+/*
+ * Copyright (c) 2021, salesforce.com, inc.
+ * All rights reserved.
+ * SPDX-License-Identifier: BSD-3-Clause
+ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
+ */
+import React from 'react'
+import {waitFor} from '@testing-library/react'
+import {rest} from 'msw'
+import {
+ renderWithProviders,
+ createPathWithDefaults
+} from '@salesforce/retail-react-app/app/utils/test-utils'
+import Login from '.'
+import {BrowserRouter as Router, Route} from 'react-router-dom'
+import Account from '@salesforce/retail-react-app/app/pages/account'
+import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
+import {mockedRegisteredCustomer} from '@salesforce/retail-react-app/app/mocks/mock-data'
+import {AuthHelpers} from '@salesforce/commerce-sdk-react'
+
+const mockMergedBasket = {
+ basketId: 'a10ff320829cb0eef93ca5310a',
+ currency: 'USD',
+ customerInfo: {
+ customerId: 'registeredCustomerId',
+ email: 'customer@test.com'
+ }
+}
+
+const mockAuthHelperFunctions = {
+ [AuthHelpers.LoginPasswordlessUser]: {mutateAsync: jest.fn()}
+}
+
+const MockedComponent = () => {
+ const match = {
+ params: {pageName: 'profile'}
+ }
+ return (
+
+
+
+
+
+
+ )
+}
+
+jest.mock('react-router', () => {
+ return {
+ ...jest.requireActual('react-router'),
+ useRouteMatch: () => {
+ return {path: '/passwordless-login-landing'}
+ }
+ }
+})
+
+jest.mock('@salesforce/commerce-sdk-react', () => {
+ const originalModule = jest.requireActual('@salesforce/commerce-sdk-react')
+ return {
+ ...originalModule,
+ useAuthHelper: jest
+ .fn()
+ .mockImplementation((helperType) => mockAuthHelperFunctions[helperType]),
+ useCustomerBaskets: () => {
+ return {data: mockMergedBasket, isSuccess: true}
+ },
+ useCustomerType: jest.fn(() => {
+ return {isRegistered: true, customerType: 'guest'}
+ })
+ }
+})
+
+// Set up and clean up
+beforeEach(() => {
+ global.server.use(
+ rest.post('*/customers', (req, res, ctx) => {
+ return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer))
+ }),
+ rest.get('*/customers/:customerId', (req, res, ctx) => {
+ const {customerId} = req.params
+ if (customerId === 'customerId') {
+ return res(
+ ctx.delay(0),
+ ctx.status(200),
+ ctx.json({
+ authType: 'guest',
+ customerId: 'customerid'
+ })
+ )
+ }
+ return res(ctx.delay(0), ctx.status(200), ctx.json(mockedRegisteredCustomer))
+ })
+ )
+})
+afterEach(() => {
+ jest.resetModules()
+})
+
+describe('Passwordless landing tests', function () {
+ test('redirects to account page when redirect url is not passed', async () => {
+ const token = '12345678'
+ window.history.pushState(
+ {},
+ 'Passwordless Login Landing',
+ createPathWithDefaults(`/passwordless-login-landing?token=${token}`)
+ )
+ renderWithProviders(, {
+ wrapperProps: {
+ siteAlias: 'uk',
+ locale: {id: 'en-GB'},
+ appConfig: mockConfig.app
+ }
+ })
+
+ expect(
+ mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
+ ).toHaveBeenCalledWith({
+ pwdlessLoginToken: token
+ })
+
+ await waitFor(() => {
+ expect(window.location.pathname).toBe('/uk/en-GB/account')
+ })
+ })
+
+ test('redirects to redirectUrl when passed as param', async () => {
+ const token = '12345678'
+ const redirectUrl = '/womens-tops'
+ window.history.pushState(
+ {},
+ 'Passwordless Login Landing',
+ createPathWithDefaults(
+ `/passwordless-login-landing?token=${token}&redirect_url=${redirectUrl}`
+ )
+ )
+ renderWithProviders(, {
+ wrapperProps: {
+ siteAlias: 'uk',
+ locale: {id: 'en-GB'},
+ appConfig: mockConfig.app
+ }
+ })
+
+ expect(
+ mockAuthHelperFunctions[AuthHelpers.LoginPasswordlessUser].mutateAsync
+ ).toHaveBeenCalledWith({
+ pwdlessLoginToken: token
+ })
+
+ await waitFor(() => {
+ expect(window.location.pathname).toBe('/uk/en-GB/womens-tops')
+ })
+ })
+})
diff --git a/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx
index 97b8a35361..956bb3a1e2 100644
--- a/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx
+++ b/packages/template-retail-react-app/app/pages/reset-password/reset-password-landing.jsx
@@ -35,8 +35,8 @@ const ResetPasswordLanding = () => {
const {search} = useLocation()
const navigate = useNavigation()
const queryParams = new URLSearchParams(search)
- const email = queryParams.get('email')
- const token = queryParams.get('token')
+ const email = decodeURIComponent(queryParams.get('email'))
+ const token = decodeURIComponent(queryParams.get('token'))
const fields = useUpdatePasswordFields({form})
const password = form.watch('password')
const {resetPassword} = usePasswordReset()
diff --git a/packages/template-retail-react-app/app/ssr.js b/packages/template-retail-react-app/app/ssr.js
index f152b457ac..88e39f6580 100644
--- a/packages/template-retail-react-app/app/ssr.js
+++ b/packages/template-retail-react-app/app/ssr.js
@@ -80,7 +80,7 @@ const passwordlessLoginCallback =
// Reusable function to handle sending a magic link email.
// By default, this implementation uses Marketing Cloud.
-async function sendMagicLinkEmail(req, res, landingPath, emailTemplate) {
+async function sendMagicLinkEmail(req, res, landingPath, emailTemplate, redirectUrl) {
// Extract the base URL from the request
const base = req.protocol + '://' + req.get('host')
@@ -88,10 +88,13 @@ async function sendMagicLinkEmail(req, res, landingPath, emailTemplate) {
const {email_id, token} = req.body
// Construct the magic link URL
- let magicLink = `${base}${landingPath}?token=${token}`
+ let magicLink = `${base}${landingPath}?token=${encodeURIComponent(token)}`
if (landingPath === RESET_PASSWORD_LANDING_PATH) {
// Add email query parameter for reset password flow
- magicLink += `&email=${email_id}`
+ magicLink += `&email=${encodeURIComponent(email_id)}`
+ }
+ if (landingPath === PASSWORDLESS_LOGIN_LANDING_PATH && redirectUrl) {
+ magicLink += `&redirect_url=${encodeURIComponent(redirectUrl)}`
}
// Call the emailLink function to send an email with the magic link using Marketing Cloud
@@ -147,12 +150,14 @@ const {handler} = runtime.createHandler(options, (app) => {
// https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas-passwordless-login.html#receive-the-callback
app.post(passwordlessLoginCallback, (req, res) => {
const slasCallbackToken = req.headers['x-slas-callback-token']
+ const redirectUrl = req.query.redirectUrl
validateSlasCallbackToken(slasCallbackToken).then(() => {
sendMagicLinkEmail(
req,
res,
PASSWORDLESS_LOGIN_LANDING_PATH,
- process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE
+ process.env.MARKETING_CLOUD_PASSWORDLESS_LOGIN_TEMPLATE,
+ redirectUrl
)
})
})
diff --git a/packages/template-retail-react-app/app/utils/jwt-utils.js b/packages/template-retail-react-app/app/utils/jwt-utils.js
index 2f73d5c99a..b57cc8eb5e 100644
--- a/packages/template-retail-react-app/app/utils/jwt-utils.js
+++ b/packages/template-retail-react-app/app/utils/jwt-utils.js
@@ -26,7 +26,9 @@ export const createRemoteJWKSet = (tenantId) => {
const shortCode = appConfig.commerceAPI.parameters.shortCode
const configTenantId = appConfig.commerceAPI.parameters.organizationId.replace(/^f_ecom_/, '')
if (tenantId !== configTenantId) {
- throw new Error(`The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`)
+ throw new Error(
+ `The tenant ID in your PWA Kit configuration ("${configTenantId}") does not match the tenant ID in the SLAS callback token ("${tenantId}").`
+ )
}
const JWKS_URI = `${appOrigin}/${shortCode}/${tenantId}/oauth2/jwks`
return joseCreateRemoteJWKSet(new URL(JWKS_URI))
@@ -77,7 +79,7 @@ export async function jwksCaching(req, res, options) {
// JWKS rotate every 30 days. For now, cache response for 14 days so that
// fetches only need to happen twice a month
- res.set('Cache-Control', 'public, max-age=1209600')
+ res.set('Cache-Control', 'public, max-age=1209600, stale-while-revalidate=86400')
return res.json(await response.json())
} catch (error) {