Merged in feat/webviews (pull request #198)
Feat/webviews Approved-by: Michael Zetterberg
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { firaMono, firaSans } from "@/app/[lang]/(live)/fonts"
|
||||
import { firaMono, firaSans } from "@/app/fonts"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { findLang } from "@/constants/languages"
|
||||
import { login } from "@/constants/routes/handleAuth"
|
||||
import { SESSION_EXPIRED } from "@/server/errors/trpc"
|
||||
|
||||
import { firaMono, firaSans } from "@/app/[lang]/(live)/fonts"
|
||||
import { firaMono, firaSans } from "@/app/fonts"
|
||||
|
||||
import styles from "./error.module.css"
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@ import Script from "next/script"
|
||||
|
||||
import TrpcProvider from "@/lib/trpc/Provider"
|
||||
|
||||
import { biroScriptPlus, firaMono, firaSans } from "@/app/fonts"
|
||||
import AdobeScript from "@/components/Current/AdobeScript"
|
||||
import VwoScript from "@/components/Current/VwoScript"
|
||||
|
||||
import { biroScriptPlus, firaMono, firaSans } from "./fonts"
|
||||
|
||||
import type { Metadata } from "next"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { firaMono, firaSans } from "@/app/[lang]/(live)/fonts"
|
||||
import { firaMono, firaSans } from "@/app/fonts"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
|
||||
26
app/[lang]/webview/[contentType]/[uid]/page.tsx
Normal file
26
app/[lang]/webview/[contentType]/[uid]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import AccountPage from "@/components/ContentType/Webviews/AccountPage"
|
||||
import LoyaltyPage from "@/components/ContentType/Webviews/LoyaltyPage"
|
||||
|
||||
import {
|
||||
ContentTypeWebviewParams,
|
||||
LangParams,
|
||||
PageArgs,
|
||||
UIDParams,
|
||||
} from "@/types/params"
|
||||
|
||||
export default async function ContentTypePage({
|
||||
params,
|
||||
}: PageArgs<LangParams & ContentTypeWebviewParams & UIDParams, {}>) {
|
||||
switch (params.contentType) {
|
||||
case "loyalty-page":
|
||||
return <LoyaltyPage lang={params.lang} />
|
||||
case "account-page":
|
||||
return <AccountPage lang={params.lang} />
|
||||
default:
|
||||
const type: never = params.contentType
|
||||
console.error(`Unsupported content type given: ${type}`)
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
5
app/[lang]/webview/layout.module.css
Normal file
5
app/[lang]/webview/layout.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.layout {
|
||||
font-family: var(--ff-fira-sans);
|
||||
background-color: var(--Scandic-Brand-Warm-White);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
import "@/app/globals.css"
|
||||
import "@scandic-hotels/design-system/style.css"
|
||||
|
||||
import TrpcProvider from "@/lib/trpc/Provider"
|
||||
|
||||
import { biroScriptPlus, firaMono, firaSans } from "@/app/fonts"
|
||||
|
||||
import styles from "./layout.module.css"
|
||||
|
||||
import type { Metadata } from "next"
|
||||
|
||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||
@@ -12,7 +21,11 @@ export default function RootLayout({
|
||||
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
||||
return (
|
||||
<html lang={params.lang}>
|
||||
<body>{children}</body>
|
||||
<body
|
||||
className={`${firaMono.variable} ${firaSans.variable} ${biroScriptPlus.variable} ${styles.layout}`}
|
||||
>
|
||||
<TrpcProvider lang={params.lang}>{children}</TrpcProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
6
app/[lang]/webview/refresh/page.module.css
Normal file
6
app/[lang]/webview/refresh/page.module.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
11
app/[lang]/webview/refresh/page.tsx
Normal file
11
app/[lang]/webview/refresh/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import styles from "./page.module.css"
|
||||
|
||||
export default function Refresh() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hello World from Webview",
|
||||
}
|
||||
|
||||
export default function WebViewTestPage() {
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<h1>Hello From WebView Test Page!</h1>
|
||||
</header>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const firaSans = Fira_Sans({
|
||||
export const biroScriptPlus = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../../public/_static/fonts/biro-script-plus/Biro-Script-Plus.ttf",
|
||||
path: "../public/_static/fonts/biro-script-plus/Biro-Script-Plus.ttf",
|
||||
style: "normal",
|
||||
weight: "500",
|
||||
},
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { firaMono, firaSans } from "@/app/[lang]/(live)/fonts"
|
||||
import { firaMono, firaSans } from "@/app/fonts"
|
||||
|
||||
import styles from "./global-error.module.css"
|
||||
|
||||
|
||||
26
components/ContentType/Webviews/AccountPage.tsx
Normal file
26
components/ContentType/Webviews/AccountPage.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import "@/app/globals.css"
|
||||
import "@scandic-hotels/design-system/style.css"
|
||||
|
||||
import { overview } from "@/constants/routes/webviews"
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import MaxWidth from "@/components/MaxWidth"
|
||||
import Content from "@/components/MyPages/AccountPage/Webview/Content"
|
||||
import LinkToOverview from "@/components/Webviews/LinkToOverview"
|
||||
|
||||
import styles from "./accountPage.module.css"
|
||||
|
||||
import { LangParams } from "@/types/params"
|
||||
|
||||
export default async function MyPages({ lang }: LangParams) {
|
||||
const accountPage = await serverClient().contentstack.accountPage.get()
|
||||
|
||||
const linkToOverview = `/${lang}/webview${accountPage.url}` !== overview[lang]
|
||||
|
||||
return (
|
||||
<MaxWidth className={styles.blocks} tag="main">
|
||||
{linkToOverview ? <LinkToOverview lang={lang} /> : null}
|
||||
<Content lang={lang} content={accountPage.content} />
|
||||
</MaxWidth>
|
||||
)
|
||||
}
|
||||
29
components/ContentType/Webviews/LoyaltyPage.tsx
Normal file
29
components/ContentType/Webviews/LoyaltyPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { Blocks } from "@/components/Loyalty/Blocks/WebView"
|
||||
import Sidebar from "@/components/Loyalty/Sidebar"
|
||||
import MaxWidth from "@/components/MaxWidth"
|
||||
import LinkToOverview from "@/components/Webviews/LinkToOverview"
|
||||
|
||||
import styles from "./loyaltyPage.module.css"
|
||||
|
||||
import { LangParams } from "@/types/params"
|
||||
|
||||
export default async function AboutScandicFriends({ lang }: LangParams) {
|
||||
const loyaltyPage = await serverClient().contentstack.loyaltyPage.get()
|
||||
return (
|
||||
<section className={styles.content}>
|
||||
<LinkToOverview lang={lang} />
|
||||
|
||||
{loyaltyPage.sidebar ? (
|
||||
<section className={styles.sidebar}>
|
||||
<Sidebar blocks={loyaltyPage.sidebar} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<MaxWidth tag="main">
|
||||
<Blocks blocks={loyaltyPage.blocks} lang={lang} />
|
||||
</MaxWidth>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
5
components/ContentType/Webviews/accountPage.module.css
Normal file
5
components/ContentType/Webviews/accountPage.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.blocks {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x5);
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
10
components/ContentType/Webviews/loyaltyPage.module.css
Normal file
10
components/ContentType/Webviews/loyaltyPage.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.content {
|
||||
display: grid;
|
||||
padding: var(--Spacing-x2);
|
||||
gap: var(--Spacing-x5);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
margin-left: calc(var(--Spacing-x2) * -1);
|
||||
margin-right: calc(var(--Spacing-x2) * -1);
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
width: 3px;
|
||||
height: 9px;
|
||||
border-radius: 20%;
|
||||
background: var(--Brand-Main-Strong);
|
||||
background: var(--Scandic-Brand-Burgundy);
|
||||
}
|
||||
|
||||
.spinner div:nth-child(1) {
|
||||
|
||||
54
components/Loyalty/Blocks/WebView/index.tsx
Normal file
54
components/Loyalty/Blocks/WebView/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import JsonToHtml from "@/components/JsonToHtml"
|
||||
import DynamicContentBlock from "@/components/Loyalty/Blocks/DynamicContent"
|
||||
import Shortcuts from "@/components/MyPages/Blocks/Shortcuts"
|
||||
import { modWebviewLink } from "@/utils/webviews"
|
||||
|
||||
import CardsGrid from "../CardsGrid"
|
||||
|
||||
import type { BlocksProps } from "@/types/components/loyalty/blocks"
|
||||
import { LoyaltyBlocksTypenameEnum } from "@/types/components/loyalty/enums"
|
||||
import { LangParams } from "@/types/params"
|
||||
|
||||
export function Blocks({ lang, blocks }: BlocksProps & LangParams) {
|
||||
return blocks.map((block) => {
|
||||
switch (block.__typename) {
|
||||
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksCardsGrid:
|
||||
return <CardsGrid cards_grid={block.cards_grid} />
|
||||
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksContent:
|
||||
return (
|
||||
<section>
|
||||
<JsonToHtml
|
||||
nodes={block.content.content.json.children}
|
||||
embeds={block.content.content.embedded_itemsConnection.edges}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksDynamicContent:
|
||||
const dynamicContent = {
|
||||
...block.dynamic_content,
|
||||
link: block.dynamic_content.link
|
||||
? {
|
||||
...block.dynamic_content.link,
|
||||
href: modWebviewLink(block.dynamic_content.link.href, lang),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
return <DynamicContentBlock dynamicContent={dynamicContent} />
|
||||
case LoyaltyBlocksTypenameEnum.LoyaltyPageBlocksShortcuts:
|
||||
const shortcuts = block.shortcuts.shortcuts.map((shortcut) => ({
|
||||
...shortcut,
|
||||
url: modWebviewLink(shortcut.url, lang),
|
||||
}))
|
||||
return (
|
||||
<Shortcuts
|
||||
shortcuts={shortcuts}
|
||||
title={block.shortcuts.title}
|
||||
subtitle={block.shortcuts.preamble}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
}
|
||||
101
components/MyPages/AccountPage/Webview/Content.tsx
Normal file
101
components/MyPages/AccountPage/Webview/Content.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import JsonToHtml from "@/components/JsonToHtml"
|
||||
import Overview from "@/components/MyPages/Blocks/Overview"
|
||||
import Shortcuts from "@/components/MyPages/Blocks/Shortcuts"
|
||||
import { modWebviewLink } from "@/utils/webviews"
|
||||
|
||||
import CurrentBenefitsBlock from "../../Blocks/Benefits/CurrentLevel"
|
||||
import NextLevelBenefitsBlock from "../../Blocks/Benefits/NextLevel"
|
||||
import CurrentPointsBalance from "../../Blocks/Points/CurrentPointsBalance"
|
||||
import EarnAndBurn from "../../Blocks/Points/EarnAndBurn"
|
||||
|
||||
import {
|
||||
AccountPageContentProps,
|
||||
ContentProps,
|
||||
} from "@/types/components/myPages/myPage/accountPage"
|
||||
import {
|
||||
ContentEntries,
|
||||
DynamicContentComponents,
|
||||
} from "@/types/components/myPages/myPage/enums"
|
||||
|
||||
function DynamicComponent({ component, props }: AccountPageContentProps) {
|
||||
switch (component) {
|
||||
case DynamicContentComponents.membership_overview:
|
||||
return <Overview title={props.title} />
|
||||
case DynamicContentComponents.current_benefits:
|
||||
return <CurrentBenefitsBlock {...props} />
|
||||
case DynamicContentComponents.next_benefits:
|
||||
return <NextLevelBenefitsBlock {...props} />
|
||||
case DynamicContentComponents.my_points:
|
||||
return <CurrentPointsBalance {...props} />
|
||||
case DynamicContentComponents.expiring_points:
|
||||
// TODO: Add once available
|
||||
// return <ExpiringPoints />
|
||||
return null
|
||||
case DynamicContentComponents.earn_and_burn:
|
||||
return <EarnAndBurn {...props} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function Content({ lang, content }: ContentProps) {
|
||||
return (
|
||||
<>
|
||||
{content.map((item) => {
|
||||
switch (item.__typename) {
|
||||
case ContentEntries.AccountPageContentDynamicContent:
|
||||
const link = item.dynamic_content.link.linkConnection.edges.length
|
||||
? {
|
||||
href:
|
||||
item.dynamic_content.link.linkConnection.edges[0].node
|
||||
.original_url ||
|
||||
`/${lang}/webview${item.dynamic_content.link.linkConnection.edges[0].node.url}`,
|
||||
text: item.dynamic_content.link.link_text,
|
||||
}
|
||||
: null
|
||||
|
||||
const componentProps = {
|
||||
lang,
|
||||
title: item.dynamic_content.title,
|
||||
// TODO: rename preamble to subtitle in Contentstack?
|
||||
subtitle: item.dynamic_content.preamble,
|
||||
...(link && { link }),
|
||||
}
|
||||
return (
|
||||
<DynamicComponent
|
||||
component={item.dynamic_content.component}
|
||||
props={componentProps}
|
||||
/>
|
||||
)
|
||||
case ContentEntries.AccountPageContentShortcuts:
|
||||
const shortcuts = item.shortcuts.shortcuts.map((shortcut) => {
|
||||
return {
|
||||
...shortcut,
|
||||
url: modWebviewLink(shortcut.url, lang),
|
||||
}
|
||||
})
|
||||
return (
|
||||
<Shortcuts
|
||||
shortcuts={shortcuts}
|
||||
subtitle={item.shortcuts.preamble}
|
||||
title={item.shortcuts.title}
|
||||
/>
|
||||
)
|
||||
case ContentEntries.AccountPageContentTextContent:
|
||||
return (
|
||||
<section>
|
||||
<JsonToHtml
|
||||
embeds={
|
||||
item.text_content.content.embedded_itemsConnection.edges
|
||||
}
|
||||
nodes={item.text_content.content.json.children}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import styles from "./user.module.css"
|
||||
|
||||
export default async function User() {
|
||||
const user = await serverClient().user.get()
|
||||
|
||||
return (
|
||||
<div className={styles.user}>
|
||||
{user.firstName[0].toUpperCase()}
|
||||
{user.lastName[0].toUpperCase()}
|
||||
<span className={styles.alert}>1</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
.user {
|
||||
align-items: center;
|
||||
background-color: var(--some-black-color, #000);
|
||||
border-radius: 50%;
|
||||
color: var(--some-white-color, #fff);
|
||||
display: flex;
|
||||
font-family: var(--ff-fira-sans);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
height: 3.5rem;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 3.5rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
align-items: center;
|
||||
background-color: var(--some-red-color, #ed2027);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
font-size: 1rem;
|
||||
height: 2rem;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
right: -1rem;
|
||||
top: -0.5rem;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 950px) {
|
||||
.user {
|
||||
height: 2.8rem;
|
||||
width: 2.8rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
font-size: 0.6rem;
|
||||
height: 1rem;
|
||||
right: -0.2rem;
|
||||
top: -0.1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
18
components/Webviews/LinkToOverview/index.tsx
Normal file
18
components/Webviews/LinkToOverview/index.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ArrowLeft } from "react-feather"
|
||||
|
||||
import { overview } from "@/constants/routes/webviews"
|
||||
import { _ } from "@/lib/translation"
|
||||
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
|
||||
import styles from "./linkToOverview.module.css"
|
||||
|
||||
import { LangParams } from "@/types/params"
|
||||
|
||||
export default function LinkToOverview({ lang }: LangParams) {
|
||||
return (
|
||||
<Link className={styles.overviewLink} href={overview[lang]}>
|
||||
<ArrowLeft height={20} width={20} /> {_("Go back to overview")}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.overviewLink {
|
||||
font-size: var(--Spacing-x2);
|
||||
color: var(--Scandic-Brand-Burgundy, #4d001b);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
71
constants/routes/webviews.ts
Normal file
71
constants/routes/webviews.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
const myPages = {
|
||||
da: "/da/webview/mine-sider",
|
||||
de: "/de/webview/mein-profil",
|
||||
en: "/en/webview/my-pages",
|
||||
fi: "/fi/webview/minun-sivujani",
|
||||
no: "/no/webview/mine-sider",
|
||||
sv: "/sv/webview/mina-sidor",
|
||||
}
|
||||
|
||||
export const overview = {
|
||||
da: `${myPages.da}/oversigt`,
|
||||
de: `${myPages.de}/uberblick`,
|
||||
en: `${myPages.en}/overview`,
|
||||
fi: `${myPages.fi}/yleiskatsaus`,
|
||||
no: `${myPages.no}/oversikt`,
|
||||
sv: `${myPages.sv}/oversikt`,
|
||||
}
|
||||
|
||||
export const benefits = {
|
||||
da: `${myPages.da}/fordele`,
|
||||
de: `${myPages.de}/vorteile`,
|
||||
en: `${myPages.en}/benefits`,
|
||||
fi: `${myPages.fi}/etuja`,
|
||||
no: `${myPages.no}/fordeler`,
|
||||
sv: `${myPages.sv}/formaner`,
|
||||
}
|
||||
|
||||
export const points = {
|
||||
da: `${myPages.da}/points`,
|
||||
de: `${myPages.de}/points`,
|
||||
en: `${myPages.en}/points`,
|
||||
fi: `${myPages.fi}/points`,
|
||||
no: `${myPages.no}/points`,
|
||||
sv: `${myPages.sv}/points`,
|
||||
}
|
||||
|
||||
export const programOverview = {
|
||||
da: `/da/webview/about-scandic-friends`,
|
||||
de: `/de/webview/about-scandic-friends`,
|
||||
en: `/en/webview/about-scandic-friends`,
|
||||
fi: `/fi/webview/about-scandic-friends`,
|
||||
no: `/no/webview/om-scandic-friends`,
|
||||
sv: `/sv/webview/om-scandic-friends`,
|
||||
}
|
||||
|
||||
const refreshUrl = {
|
||||
da: `/da/webview/refresh`,
|
||||
de: `/de/webview/refresh`,
|
||||
en: `/en/webview/refresh`,
|
||||
fi: `/fi/webview/refresh`,
|
||||
no: `/no/webview/refresh`,
|
||||
sv: `/sv/webview/refresh`,
|
||||
}
|
||||
|
||||
export const webviews = [
|
||||
...Object.values(benefits),
|
||||
...Object.values(overview),
|
||||
...Object.values(points),
|
||||
...Object.values(programOverview),
|
||||
...Object.values(refreshUrl),
|
||||
]
|
||||
|
||||
export const myPagesWebviews = [
|
||||
...Object.values(benefits),
|
||||
...Object.values(overview),
|
||||
...Object.values(points),
|
||||
]
|
||||
|
||||
export const loyaltyPagesWebviews = [...Object.values(programOverview)]
|
||||
|
||||
export const refreshWebviews = [...Object.values(refreshUrl)]
|
||||
@@ -127,15 +127,6 @@ query GetLoyaltyPage($locale: String!, $uid: String!) {
|
||||
}
|
||||
}
|
||||
}
|
||||
web {
|
||||
breadcrumbs {
|
||||
title
|
||||
parents {
|
||||
href
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
system {
|
||||
uid
|
||||
created_at
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { webviews } from "@/constants/routes/webviews"
|
||||
import { appRouter } from "@/server"
|
||||
import { createContext } from "@/server/context"
|
||||
import { internalServerError } from "@/server/errors/next"
|
||||
@@ -21,8 +22,28 @@ export function serverClient() {
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
if (error.code === "UNAUTHORIZED") {
|
||||
const lang = ctx?.lang || Lang.en
|
||||
const pathname = ctx?.pathname || "/"
|
||||
let lang = Lang.en
|
||||
let pathname = "/"
|
||||
let fullUrl = "/"
|
||||
|
||||
if (ctx) {
|
||||
lang = ctx.lang
|
||||
pathname = ctx.pathname
|
||||
fullUrl = ctx.url
|
||||
}
|
||||
|
||||
const fullPathname = new URL(fullUrl).pathname
|
||||
|
||||
if (webviews.includes(fullPathname)) {
|
||||
const redirectUrl = `/${lang}/webview/refresh?returnurl=${encodeURIComponent(fullUrl)}`
|
||||
console.error(
|
||||
"Unautorized in webview, redirecting to: ",
|
||||
redirectUrl
|
||||
)
|
||||
|
||||
redirect(redirectUrl)
|
||||
}
|
||||
|
||||
redirect(
|
||||
`/${lang}/login?redirectTo=${encodeURIComponent(`/${lang}/${pathname}`)}`
|
||||
)
|
||||
|
||||
@@ -7,7 +7,10 @@ export function getDefaultRequestHeaders(request: NextRequest) {
|
||||
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("x-lang", lang)
|
||||
headers.set("x-pathname", request.nextUrl.pathname.replace(`/${lang}`, ""))
|
||||
headers.set(
|
||||
"x-pathname",
|
||||
request.nextUrl.pathname.replace(`/${lang}`, "").replace(`/webview`, "")
|
||||
)
|
||||
headers.set("x-url", request.nextUrl.href)
|
||||
|
||||
return headers
|
||||
|
||||
@@ -1,48 +1,129 @@
|
||||
import { type NextMiddleware, NextResponse } from "next/server"
|
||||
|
||||
import { findLang } from "@/constants/languages"
|
||||
import {
|
||||
loyaltyPagesWebviews,
|
||||
myPagesWebviews,
|
||||
refreshWebviews,
|
||||
webviews,
|
||||
} from "@/constants/routes/webviews"
|
||||
import { env } from "@/env/server"
|
||||
import { badRequest, internalServerError } from "@/server/errors/next"
|
||||
import { badRequest, notFound } from "@/server/errors/next"
|
||||
|
||||
import { decryptData } from "@/utils/aes"
|
||||
import { resolve as resolveEntry } from "@/utils/entry"
|
||||
|
||||
import { getDefaultRequestHeaders } from "./utils"
|
||||
|
||||
import type { MiddlewareMatcher } from "@/types/middleware"
|
||||
|
||||
export const middleware: NextMiddleware = async (request) => {
|
||||
const { nextUrl } = request
|
||||
const lang = findLang(nextUrl.pathname)
|
||||
|
||||
// If user is redirected to /lang/webview/refresh/, the webview token is invalid and we remove the cookie
|
||||
if (refreshWebviews.includes(nextUrl.pathname)) {
|
||||
return NextResponse.rewrite(
|
||||
new URL(
|
||||
`/${lang}/webview/refresh?${nextUrl.searchParams.toString()}`,
|
||||
nextUrl
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": `webviewToken=0; Max-Age=0; Secure; HttpOnly; Path=/; SameSite=Strict;`,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const pathNameWithoutLang = nextUrl.pathname.replace(`/${lang}/webview`, "")
|
||||
|
||||
const { uid } = await resolveEntry(pathNameWithoutLang, lang)
|
||||
if (!uid) {
|
||||
throw notFound(
|
||||
`Unable to resolve CMS entry for locale "${lang}": ${pathNameWithoutLang}`
|
||||
)
|
||||
}
|
||||
const headers = getDefaultRequestHeaders(request)
|
||||
headers.set("x-uid", uid)
|
||||
|
||||
const webviewToken = request.cookies.get("webviewToken")
|
||||
if (webviewToken) {
|
||||
// since the token exists, this is a subsequent visit
|
||||
// we're done, allow it
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Authorization header is required for webviews
|
||||
// It should be base64 encoded
|
||||
const authorization = request.headers.get("Authorization")!
|
||||
if (!authorization) {
|
||||
return badRequest()
|
||||
}
|
||||
|
||||
// Initialization vector header is required for webviews
|
||||
// It should be base64 encoded
|
||||
const initializationVector = request.headers.get("X-AES-IV")!
|
||||
if (!initializationVector) {
|
||||
return badRequest()
|
||||
if (myPagesWebviews.includes(nextUrl.pathname)) {
|
||||
return NextResponse.rewrite(
|
||||
new URL(`/${lang}/webview/account-page/${uid}`, nextUrl),
|
||||
{
|
||||
request: {
|
||||
headers,
|
||||
},
|
||||
}
|
||||
)
|
||||
} else if (loyaltyPagesWebviews.includes(nextUrl.pathname)) {
|
||||
return NextResponse.rewrite(
|
||||
new URL(`/${lang}/webview/loyalty-page/${uid}`, nextUrl),
|
||||
{
|
||||
request: {
|
||||
headers,
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
return notFound()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Authorization header is required for webviews
|
||||
// It should be base64 encoded
|
||||
const authorization = request.headers.get("X-Authorization")!
|
||||
if (!authorization) {
|
||||
console.error("Authorization header is missing")
|
||||
return badRequest("Authorization header is missing")
|
||||
}
|
||||
|
||||
// Initialization vector header is required for webviews
|
||||
// It should be base64 encoded
|
||||
const initializationVector = request.headers.get("X-AES-IV")!
|
||||
if (!initializationVector) {
|
||||
console.error("initializationVector header is missing")
|
||||
return badRequest("initializationVector header is missing")
|
||||
}
|
||||
|
||||
const decryptedData = await decryptData(
|
||||
env.WEBVIEW_ENCRYPTION_KEY,
|
||||
initializationVector,
|
||||
authorization
|
||||
)
|
||||
|
||||
// Pass the webview token via cookie to the page
|
||||
return NextResponse.next({
|
||||
headers: {
|
||||
"Set-Cookie": `webviewToken=${decryptedData}; Secure; HttpOnly;`,
|
||||
},
|
||||
})
|
||||
headers.append("Cookie", `webviewToken=${decryptedData}`)
|
||||
|
||||
if (myPagesWebviews.includes(nextUrl.pathname)) {
|
||||
return NextResponse.rewrite(
|
||||
new URL(`/${lang}/webview/account-page/${uid}`, nextUrl),
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": `webviewToken=${decryptedData}; Secure; HttpOnly; Path=/; SameSite=Strict;`,
|
||||
},
|
||||
request: {
|
||||
headers,
|
||||
},
|
||||
}
|
||||
)
|
||||
} else if (loyaltyPagesWebviews.includes(nextUrl.pathname)) {
|
||||
return NextResponse.rewrite(
|
||||
new URL(`/${lang}/webview/loyalty-page/${uid}`, nextUrl),
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": `webviewToken=${decryptedData}; Secure; HttpOnly; Path=/; SameSite=Strict;`,
|
||||
},
|
||||
request: {
|
||||
headers,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error(`${e.name}: ${e.message}`)
|
||||
@@ -54,7 +135,6 @@ export const middleware: NextMiddleware = async (request) => {
|
||||
|
||||
export const matcher: MiddlewareMatcher = (request) => {
|
||||
const { nextUrl } = request
|
||||
const lang = findLang(nextUrl.pathname)
|
||||
const pathNameWithoutLang = nextUrl.pathname.replace(`/${lang}`, "")
|
||||
return pathNameWithoutLang.startsWith("/webview/")
|
||||
|
||||
return webviews.includes(nextUrl.pathname)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { headers } from "next/headers"
|
||||
import { cookies, headers } from "next/headers"
|
||||
import { type Session } from "next-auth"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
|
||||
import { unauthorizedError } from "./errors/trpc"
|
||||
|
||||
typeof auth
|
||||
|
||||
type CreateContextOptions = {
|
||||
auth: typeof auth
|
||||
auth: () => Promise<Session>
|
||||
lang: Lang
|
||||
pathname: string
|
||||
uid?: string | null
|
||||
url: string
|
||||
webToken?: string
|
||||
}
|
||||
|
||||
/** Use this helper for:
|
||||
@@ -23,6 +29,7 @@ export function createContextInner(opts: CreateContextOptions) {
|
||||
pathname: opts.pathname,
|
||||
uid: opts.uid,
|
||||
url: opts.url,
|
||||
webToken: opts.webToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +40,24 @@ export function createContextInner(opts: CreateContextOptions) {
|
||||
export function createContext() {
|
||||
const h = headers()
|
||||
|
||||
const cookie = cookies()
|
||||
const webviewTokenCookie = cookie.get("webviewToken")
|
||||
|
||||
return createContextInner({
|
||||
auth,
|
||||
auth: async () => {
|
||||
const session = await auth()
|
||||
const webToken = webviewTokenCookie?.value
|
||||
if (!session?.token && !webToken) {
|
||||
throw unauthorizedError()
|
||||
}
|
||||
|
||||
return session || ({ token: { access_token: webToken } } as Session)
|
||||
},
|
||||
lang: h.get("x-lang") as Lang,
|
||||
pathname: h.get("x-pathname")!,
|
||||
uid: h.get("x-uid"),
|
||||
url: h.get("x-url")!,
|
||||
webToken: webviewTokenCookie?.value,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ export const contentstackProcedure = t.procedure.use(async function (opts) {
|
||||
export const protectedProcedure = t.procedure.use(async function (opts) {
|
||||
const authRequired = opts.meta?.authRequired ?? true
|
||||
const session = await opts.ctx.auth()
|
||||
|
||||
if (!authRequired && env.NODE_ENV === "development") {
|
||||
console.info(
|
||||
`❌❌❌❌ You are opting out of authorization, if its done on purpose maybe you should use the publicProcedure instead. ❌❌❌❌`
|
||||
@@ -42,10 +41,6 @@ export const protectedProcedure = t.procedure.use(async function (opts) {
|
||||
throw sessionExpiredError()
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
throw unauthorizedError()
|
||||
}
|
||||
|
||||
return opts.next({
|
||||
ctx: {
|
||||
session,
|
||||
|
||||
@@ -20,6 +20,10 @@ export type ContentTypeParams = {
|
||||
contentType: "loyalty-page" | "content-page"
|
||||
}
|
||||
|
||||
export type ContentTypeWebviewParams = {
|
||||
contentType: "loyalty-page" | "account-page"
|
||||
}
|
||||
|
||||
export type UIDParams = {
|
||||
uid: string
|
||||
}
|
||||
|
||||
13
utils/webviews.ts
Normal file
13
utils/webviews.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { webviews } from "@/constants/routes/webviews"
|
||||
|
||||
export function modWebviewLink(url: string, lang: Lang) {
|
||||
const urlWithoutLang = url.replace(`/${lang}`, "")
|
||||
|
||||
const webviewUrl = `/${lang}/webview${urlWithoutLang}`
|
||||
if (webviews.includes(webviewUrl) || url.startsWith("/webview")) {
|
||||
return webviewUrl
|
||||
} else {
|
||||
return url
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user