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"
|
import styles from "./layout.module.css"
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { findLang } from "@/constants/languages"
|
|||||||
import { login } from "@/constants/routes/handleAuth"
|
import { login } from "@/constants/routes/handleAuth"
|
||||||
import { SESSION_EXPIRED } from "@/server/errors/trpc"
|
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"
|
import styles from "./error.module.css"
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import Script from "next/script"
|
|||||||
|
|
||||||
import TrpcProvider from "@/lib/trpc/Provider"
|
import TrpcProvider from "@/lib/trpc/Provider"
|
||||||
|
|
||||||
|
import { biroScriptPlus, firaMono, firaSans } from "@/app/fonts"
|
||||||
import AdobeScript from "@/components/Current/AdobeScript"
|
import AdobeScript from "@/components/Current/AdobeScript"
|
||||||
import VwoScript from "@/components/Current/VwoScript"
|
import VwoScript from "@/components/Current/VwoScript"
|
||||||
|
|
||||||
import { biroScriptPlus, firaMono, firaSans } from "./fonts"
|
|
||||||
|
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
|
||||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
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"
|
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 { Metadata } from "next"
|
||||||
|
|
||||||
import type { LangParams, LayoutArgs } from "@/types/params"
|
import type { LangParams, LayoutArgs } from "@/types/params"
|
||||||
@@ -12,7 +21,11 @@ export default function RootLayout({
|
|||||||
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
|
||||||
return (
|
return (
|
||||||
<html lang={params.lang}>
|
<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>
|
</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({
|
export const biroScriptPlus = localFont({
|
||||||
src: [
|
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",
|
style: "normal",
|
||||||
weight: "500",
|
weight: "500",
|
||||||
},
|
},
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { firaMono, firaSans } from "@/app/[lang]/(live)/fonts"
|
import { firaMono, firaSans } from "@/app/fonts"
|
||||||
|
|
||||||
import styles from "./global-error.module.css"
|
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;
|
width: 3px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
border-radius: 20%;
|
border-radius: 20%;
|
||||||
background: var(--Brand-Main-Strong);
|
background: var(--Scandic-Brand-Burgundy);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner div:nth-child(1) {
|
.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 {
|
system {
|
||||||
uid
|
uid
|
||||||
created_at
|
created_at
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { Lang } from "@/constants/languages"
|
import { Lang } from "@/constants/languages"
|
||||||
|
import { webviews } from "@/constants/routes/webviews"
|
||||||
import { appRouter } from "@/server"
|
import { appRouter } from "@/server"
|
||||||
import { createContext } from "@/server/context"
|
import { createContext } from "@/server/context"
|
||||||
import { internalServerError } from "@/server/errors/next"
|
import { internalServerError } from "@/server/errors/next"
|
||||||
@@ -21,8 +22,28 @@ export function serverClient() {
|
|||||||
|
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
if (error.code === "UNAUTHORIZED") {
|
if (error.code === "UNAUTHORIZED") {
|
||||||
const lang = ctx?.lang || Lang.en
|
let lang = Lang.en
|
||||||
const pathname = ctx?.pathname || "/"
|
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(
|
redirect(
|
||||||
`/${lang}/login?redirectTo=${encodeURIComponent(`/${lang}/${pathname}`)}`
|
`/${lang}/login?redirectTo=${encodeURIComponent(`/${lang}/${pathname}`)}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export function getDefaultRequestHeaders(request: NextRequest) {
|
|||||||
|
|
||||||
const headers = new Headers(request.headers)
|
const headers = new Headers(request.headers)
|
||||||
headers.set("x-lang", lang)
|
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)
|
headers.set("x-url", request.nextUrl.href)
|
||||||
|
|
||||||
return headers
|
return headers
|
||||||
|
|||||||
@@ -1,48 +1,129 @@
|
|||||||
import { type NextMiddleware, NextResponse } from "next/server"
|
import { type NextMiddleware, NextResponse } from "next/server"
|
||||||
|
|
||||||
import { findLang } from "@/constants/languages"
|
import { findLang } from "@/constants/languages"
|
||||||
|
import {
|
||||||
|
loyaltyPagesWebviews,
|
||||||
|
myPagesWebviews,
|
||||||
|
refreshWebviews,
|
||||||
|
webviews,
|
||||||
|
} from "@/constants/routes/webviews"
|
||||||
import { env } from "@/env/server"
|
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 { decryptData } from "@/utils/aes"
|
||||||
|
import { resolve as resolveEntry } from "@/utils/entry"
|
||||||
|
|
||||||
|
import { getDefaultRequestHeaders } from "./utils"
|
||||||
|
|
||||||
import type { MiddlewareMatcher } from "@/types/middleware"
|
import type { MiddlewareMatcher } from "@/types/middleware"
|
||||||
|
|
||||||
export const middleware: NextMiddleware = async (request) => {
|
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")
|
const webviewToken = request.cookies.get("webviewToken")
|
||||||
if (webviewToken) {
|
if (webviewToken) {
|
||||||
// since the token exists, this is a subsequent visit
|
// since the token exists, this is a subsequent visit
|
||||||
// we're done, allow it
|
// we're done, allow it
|
||||||
return NextResponse.next()
|
if (myPagesWebviews.includes(nextUrl.pathname)) {
|
||||||
}
|
return NextResponse.rewrite(
|
||||||
|
new URL(`/${lang}/webview/account-page/${uid}`, nextUrl),
|
||||||
// Authorization header is required for webviews
|
{
|
||||||
// It should be base64 encoded
|
request: {
|
||||||
const authorization = request.headers.get("Authorization")!
|
headers,
|
||||||
if (!authorization) {
|
},
|
||||||
return badRequest()
|
}
|
||||||
}
|
)
|
||||||
|
} else if (loyaltyPagesWebviews.includes(nextUrl.pathname)) {
|
||||||
// Initialization vector header is required for webviews
|
return NextResponse.rewrite(
|
||||||
// It should be base64 encoded
|
new URL(`/${lang}/webview/loyalty-page/${uid}`, nextUrl),
|
||||||
const initializationVector = request.headers.get("X-AES-IV")!
|
{
|
||||||
if (!initializationVector) {
|
request: {
|
||||||
return badRequest()
|
headers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(
|
const decryptedData = await decryptData(
|
||||||
env.WEBVIEW_ENCRYPTION_KEY,
|
env.WEBVIEW_ENCRYPTION_KEY,
|
||||||
initializationVector,
|
initializationVector,
|
||||||
authorization
|
authorization
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pass the webview token via cookie to the page
|
headers.append("Cookie", `webviewToken=${decryptedData}`)
|
||||||
return NextResponse.next({
|
|
||||||
headers: {
|
if (myPagesWebviews.includes(nextUrl.pathname)) {
|
||||||
"Set-Cookie": `webviewToken=${decryptedData}; Secure; HttpOnly;`,
|
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) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
console.error(`${e.name}: ${e.message}`)
|
console.error(`${e.name}: ${e.message}`)
|
||||||
@@ -54,7 +135,6 @@ export const middleware: NextMiddleware = async (request) => {
|
|||||||
|
|
||||||
export const matcher: MiddlewareMatcher = (request) => {
|
export const matcher: MiddlewareMatcher = (request) => {
|
||||||
const { nextUrl } = request
|
const { nextUrl } = request
|
||||||
const lang = findLang(nextUrl.pathname)
|
|
||||||
const pathNameWithoutLang = nextUrl.pathname.replace(`/${lang}`, "")
|
return webviews.includes(nextUrl.pathname)
|
||||||
return pathNameWithoutLang.startsWith("/webview/")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Lang } from "@/constants/languages"
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
|
||||||
|
import { unauthorizedError } from "./errors/trpc"
|
||||||
|
|
||||||
|
typeof auth
|
||||||
|
|
||||||
type CreateContextOptions = {
|
type CreateContextOptions = {
|
||||||
auth: typeof auth
|
auth: () => Promise<Session>
|
||||||
lang: Lang
|
lang: Lang
|
||||||
pathname: string
|
pathname: string
|
||||||
uid?: string | null
|
uid?: string | null
|
||||||
url: string
|
url: string
|
||||||
|
webToken?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Use this helper for:
|
/** Use this helper for:
|
||||||
@@ -23,6 +29,7 @@ export function createContextInner(opts: CreateContextOptions) {
|
|||||||
pathname: opts.pathname,
|
pathname: opts.pathname,
|
||||||
uid: opts.uid,
|
uid: opts.uid,
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
|
webToken: opts.webToken,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,12 +40,24 @@ export function createContextInner(opts: CreateContextOptions) {
|
|||||||
export function createContext() {
|
export function createContext() {
|
||||||
const h = headers()
|
const h = headers()
|
||||||
|
|
||||||
|
const cookie = cookies()
|
||||||
|
const webviewTokenCookie = cookie.get("webviewToken")
|
||||||
|
|
||||||
return createContextInner({
|
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,
|
lang: h.get("x-lang") as Lang,
|
||||||
pathname: h.get("x-pathname")!,
|
pathname: h.get("x-pathname")!,
|
||||||
uid: h.get("x-uid"),
|
uid: h.get("x-uid"),
|
||||||
url: h.get("x-url")!,
|
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) {
|
export const protectedProcedure = t.procedure.use(async function (opts) {
|
||||||
const authRequired = opts.meta?.authRequired ?? true
|
const authRequired = opts.meta?.authRequired ?? true
|
||||||
const session = await opts.ctx.auth()
|
const session = await opts.ctx.auth()
|
||||||
|
|
||||||
if (!authRequired && env.NODE_ENV === "development") {
|
if (!authRequired && env.NODE_ENV === "development") {
|
||||||
console.info(
|
console.info(
|
||||||
`❌❌❌❌ You are opting out of authorization, if its done on purpose maybe you should use the publicProcedure instead. ❌❌❌❌`
|
`❌❌❌❌ 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()
|
throw sessionExpiredError()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
throw unauthorizedError()
|
|
||||||
}
|
|
||||||
|
|
||||||
return opts.next({
|
return opts.next({
|
||||||
ctx: {
|
ctx: {
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export type ContentTypeParams = {
|
|||||||
contentType: "loyalty-page" | "content-page"
|
contentType: "loyalty-page" | "content-page"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ContentTypeWebviewParams = {
|
||||||
|
contentType: "loyalty-page" | "account-page"
|
||||||
|
}
|
||||||
|
|
||||||
export type UIDParams = {
|
export type UIDParams = {
|
||||||
uid: string
|
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