Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

@@ -0,0 +1,92 @@
.container-modal {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x3);
background-color: white;
width: 100%;
padding: var(--Spacing-x3);
text-align: center;
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
margin-top: auto;
@media screen and (min-width: 768px) {
& {
border-radius: var(--Corner-radius-Medium);
margin-top: initial;
width: 512px;
}
}
}
.otp-container {
display: flex;
gap: var(--Spacing-x1);
@media screen and (min-width: 768px) {
& {
gap: var(--Spacing-x2);
}
}
&.error .slot {
border: 1px solid var(--UI-Text-Error);
}
}
.slot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: content-box;
width: 34px;
height: 0px;
padding: var(--Spacing-x3) 0;
font-family: var(--typography-Body-Regular-fontFamily);
border: 1px solid var(--Base-Border-Normal);
border-radius: var(--Corner-radius-Medium);
text-align: center;
&.active {
border: 1px solid var(--UI-Text-Active);
outline: 1px solid var(--UI-Text-Active);
}
}
.caret {
position: absolute;
pointer-events: none;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
animation: blink 1s infinite;
& .child {
width: 1px;
height: 16px;
background-color: var(--UI-Text-Active);
}
}
.disabled-link {
cursor: default;
color: var(--UI-Text-Disabled);
opacity: 0.4;
}
.error-message {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}

View File

@@ -0,0 +1,223 @@
"use client"
import { cx } from "class-variance-authority"
import { OTPInput, type SlotProps } from "input-otp"
import { useParams, useRouter } from "next/navigation"
import { type ReactNode, useState, useTransition } from "react"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { GenericError } from "../components/GenericError"
import { SASModal, SASModalContactBlock } from "../components/SASModal"
import Loading from "./loading"
import styles from "./OneTimePasswordForm.module.css"
import type { RequestOtpError } from "@/server/routers/partners/sas/otp/request/requestOtpError"
import type { OtpError } from "./page"
type Redirect = { url: string; type?: "replace" | "push" }
export type OnSubmitHandler = (args: { otp: string }) => Promise<Redirect>
export default function OneTimePasswordForm({
heading,
ingress,
footnote,
otpLength,
onSubmit,
error,
}: {
heading: string
ingress: string | ReactNode
footnote?: string | ReactNode
otpLength: number
onSubmit: OnSubmitHandler
error?: OtpError
}) {
const router = useRouter()
const params = useParams()
const [isPending, startTransition] = useTransition()
const [disableResend, setDisableResend] = useState(false)
const intl = useIntl()
const [otp, setOtp] = useState("")
const requestOtp = trpc.partner.sas.requestOtp.useMutation({})
if (requestOtp.isPending || isPending) {
return <Loading />
}
if (requestOtp.isError) {
const cause = requestOtp.error?.data?.cause as RequestOtpError
const title = intl.formatMessage({ id: "Error requesting OTP" })
const body = getRequestErrorBody(intl, cause?.errorCode)
return (
<GenericError title={title}>
<Body textAlign="center">{body}</Body>
<SASModalContactBlock />
</GenericError>
)
}
switch (requestOtp.data?.status) {
case "ABUSED":
router.push(`/${params.lang}/sas-x-scandic/error?errorCode=tooManyCodes`)
return <Loading />
case "NOTSENT":
router.push(`/${params.lang}/sas-x-scandic/error`)
return <Loading />
case "NULL":
case "RETRY":
case "EXPIRED":
// These errors should never happen for request, but according to the API spec they can
throw new Error(`Unhandled request OTP status ${requestOtp.data?.status}`)
}
function handleRequestNewOtp(event: React.MouseEvent) {
event.preventDefault()
if (disableResend) return
setOtp("")
requestOtp.reset()
requestOtp.mutate({})
setDisableResend(true)
setTimeout(() => {
setDisableResend(false)
}, 15_000)
}
function handleOTPEntered(otp: string) {
startTransition(async () => {
const redirectRes = await onSubmit({ otp })
setOtp("")
if (redirectRes.type === "replace") {
router.replace(redirectRes.url)
return
}
router.push(redirectRes.url)
})
}
const getResendOtpLink = (str: ReactNode) => (
<Link
href="#"
onClick={handleRequestNewOtp}
color="red"
variant="default"
size="tiny"
className={disableResend ? styles["disabled-link"] : ""}
>
{str}
</Link>
)
const errorMessages: Record<OtpError, ReactNode> = {
invalidCode: intl.formatMessage({
id: "The code youve entered is incorrect.",
}),
expiredCode: intl.formatMessage(
{
id: "The code youve entered have expired. <resendOtpLink>Resend code.</resendOtpLink>",
},
{
resendOtpLink: getResendOtpLink,
}
),
}
const errorMessage = error ? errorMessages[error] : undefined
return (
<SASModal>
<Subtitle textAlign={"center"}>{heading}</Subtitle>
<div>
<Body textAlign={"center"}>{ingress}</Body>
</div>
<OTPInput
autoFocus
value={otp}
onChange={setOtp}
maxLength={otpLength}
inputMode="numeric"
onComplete={(otp) => {
handleOTPEntered(otp)
}}
containerClassName={cx(styles["otp-container"], {
[styles.error]: Boolean(error),
})}
render={({ slots }) => (
<>
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</>
)}
/>
{errorMessage && (
<div className={styles["error-message"]}>
<ErrorCircleFilledIcon height={20} width={20} color="red" />
<Caption color="red">{errorMessage}</Caption>
</div>
)}
<div>
<Footnote>{footnote}</Footnote>
<Footnote>
{intl.formatMessage(
{
id: "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
},
{
resendOtpLink: getResendOtpLink,
}
)}
</Footnote>
</div>
</SASModal>
)
}
function Slot(props: SlotProps) {
return (
<div className={`${styles.slot} ${props.isActive ? styles.active : ""}`}>
{props.char !== null && <div>{props.char}</div>}
{props.hasFakeCaret && <FakeCaret />}
</div>
)
}
function FakeCaret() {
return (
<div className={styles.caret}>
<div className={styles.child} />
</div>
)
}
const getRequestErrorBody = (
intl: ReturnType<typeof useIntl>,
errorCode: RequestOtpError["errorCode"]
) => {
switch (errorCode) {
case "TOO_MANY_REQUESTS":
return intl.formatMessage({
id: "Too many requests. Please try again later.",
})
default:
return intl.formatMessage({
id: "An error occurred while requesting a new OTP",
})
}
}

View File

@@ -0,0 +1,11 @@
import LoadingSpinner from "@/components/LoadingSpinner"
import { SASModal } from "../components/SASModal"
export default function Loading() {
return (
<SASModal>
<LoadingSpinner />
</SASModal>
)
}

View File

@@ -0,0 +1,217 @@
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { z } from "zod"
import { myPages } from "@/constants/routes/myPages"
import { serverClient } from "@/lib/trpc/server"
import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry"
import { SAS_TOKEN_STORAGE_KEY } from "../sasUtils"
import OneTimePasswordForm, {
type OnSubmitHandler,
} from "./OneTimePasswordForm"
import type { ReactNode } from "react"
import type { LangParams, PageArgs, SearchParams } from "@/types/params"
import type { Lang } from "@/constants/languages"
const otpError = z.enum(["invalidCode", "expiredCode"])
const intent = z.enum(["link", "unlink"])
const searchParamsSchema = z.object({
intent: intent,
to: z.string(),
error: otpError.optional(),
})
export type OtpError = z.infer<typeof otpError>
type Intent = z.infer<typeof intent>
export default async function SASxScandicOneTimePasswordPage({
searchParams,
params,
}: PageArgs<LangParams> & SearchParams) {
const intl = await getIntl()
const cookieStore = cookies()
const tokenCookie = cookieStore.get(SAS_TOKEN_STORAGE_KEY)
const result = searchParamsSchema.safeParse(searchParams)
if (!result.success) {
throw new Error("Invalid search params")
}
const { intent, to, error } = result.data
if (!verifyTokenValidity(tokenCookie?.value)) {
redirect(`/${params.lang}/sas-x-scandic/login?intent=${intent}`)
}
const handleOtpVerified: OnSubmitHandler = async ({ otp }) => {
"use server"
const [data, error] = await safeTry(
serverClient().partner.sas.verifyOtp({ otp })
)
if (error || !data) {
throw error || new Error("OTP verification failed")
}
switch (data.status) {
case "ABUSED":
return {
url: `/${params.lang}/sas-x-scandic/error?errorCode=tooManyFailedAttempts`,
}
case "EXPIRED": {
const search = new URLSearchParams({
...searchParams,
error: "expiredCode",
}).toString()
return {
url: `/${params.lang}/sas-x-scandic/otp?${search}`,
}
}
case "RETRY": {
const search = new URLSearchParams({
...searchParams,
error: "invalidCode",
}).toString()
return {
url: `/${params.lang}/sas-x-scandic/otp?${search}`,
}
}
case "NOTSENT":
case "NULL":
case "SENT":
case "PENDING":
// These errors should never happen for verify, but according to the API spec they can
throw new Error("Unhandled OTP status")
}
switch (intent) {
case "link":
return handleLinkAccount({ lang: params.lang })
case "unlink":
return handleUnlinkAccount({ lang: params.lang })
}
}
const maskedContactInfo = () => (
<>
<br />
<strong>{to}</strong>
<br />
</>
)
const intentDescriptions: Record<Intent, ReactNode> = {
link: intl.formatMessage(
{
id: "Please enter the code sent to <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.",
},
{ maskedContactInfo }
),
unlink: intl.formatMessage(
{
id: "Please enter the code sent to <maskedContactInfo></maskedContactInfo> in order to unlink your accounts.",
},
{ maskedContactInfo }
),
}
return (
<OneTimePasswordForm
heading={intl.formatMessage({ id: "Verification code" })}
ingress={intentDescriptions[intent]}
footnote={intl.formatMessage({
id: "This verifcation is needed for additional security.",
})}
otpLength={6}
onSubmit={handleOtpVerified}
error={error}
/>
)
}
function verifyTokenValidity(token: string | undefined) {
if (!token) {
return false
}
try {
const decoded = JSON.parse(atob(token.split(".")[1]))
const expiry = decoded.exp * 1000
return Date.now() < expiry
} catch {
return false
}
}
async function handleLinkAccount({
lang,
}: {
lang: Lang
}): ReturnType<OnSubmitHandler> {
const [res, error] = await safeTry(serverClient().partner.sas.linkAccount())
if (!res || error) {
console.error("[SAS] link account error", error)
return {
url: `/${lang}/sas-x-scandic/error`,
}
}
switch (res.linkingState) {
case "alreadyLinked":
return {
url: `/${lang}/sas-x-scandic/error?errorCode=alreadyLinked`,
type: "replace",
}
case "linked":
return {
url: `/${lang}/sas-x-scandic/link/success`,
type: "replace",
}
case "dateOfBirthMismatch":
return {
url: `/${lang}/sas-x-scandic/error?errorCode=dateOfBirthMismatch`,
type: "replace",
}
case "error":
return {
url: `/${lang}/sas-x-scandic/error`,
type: "replace",
}
}
}
async function handleUnlinkAccount({
lang,
}: {
lang: Lang
}): ReturnType<OnSubmitHandler> {
const [res, error] = await safeTry(serverClient().partner.sas.unlinkAccount())
if (!res || error) {
console.error("[SAS] unlink account error", error)
return {
url: `/${lang}/sas-x-scandic/error`,
}
}
switch (res.linkingState) {
case "unlinked":
return {
url: `/${lang}/sas-x-scandic/unlink/success`,
type: "replace",
}
case "notLinked":
return {
url: myPages[lang],
}
case "error":
return {
url: `/${lang}/sas-x-scandic/error`,
type: "replace",
}
}
}