Merge branch 'develop' into feat/sw-222-staycard-link

This commit is contained in:
Linus Flood
2024-10-10 11:05:32 +02:00
59 changed files with 1285 additions and 130 deletions

View File

@@ -0,0 +1,35 @@
import { redirect } from "next/navigation"
import { overview } from "@/constants/routes/myPages"
import { auth } from "@/auth"
import LoginButton from "@/components/Current/Header/LoginButton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./signUpVerification.module.css"
import type { SignUpVerificationProps } from "@/types/components/blocks/dynamicContent"
export default async function SignUpVerification({
dynamic_content,
}: SignUpVerificationProps) {
const session = await auth()
if (session) {
redirect(overview[getLang()])
}
const intl = await getIntl()
return (
<div className={styles.container}>
<LoginButton
className={styles.loginButton}
trackingId="signUpVerificationLogin"
position="sign up verification"
variant="signupVerification"
>
{intl.formatMessage({ id: "Proceed to login" })}
</LoginButton>
</div>
)
}

View File

@@ -0,0 +1,6 @@
.container {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-top: var(--Spacing-x3);
}

View File

@@ -0,0 +1,20 @@
import { redirect } from "next/navigation"
import { overview } from "@/constants/routes/myPages"
import { auth } from "@/auth"
import Form from "@/components/Forms/Register"
import { getLang } from "@/i18n/serverContext"
import { SignupFormWrapperProps } from "@/types/components/blocks/dynamicContent"
export default async function SignupFormWrapper({
dynamic_content,
}: SignupFormWrapperProps) {
const session = await auth()
if (session) {
// We don't want to allow users to access signup if they are already authenticated.
redirect(overview[getLang()])
}
return <Form {...dynamic_content} />
}

View File

@@ -7,6 +7,8 @@ import ExpiringPoints from "@/components/Blocks/DynamicContent/Points/ExpiringPo
import PointsOverview from "@/components/Blocks/DynamicContent/Points/Overview"
import CurrentRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/CurrentLevel"
import NextLevelRewardsBlock from "@/components/Blocks/DynamicContent/Rewards/NextLevel"
import SignupFormWrapper from "@/components/Blocks/DynamicContent/SignupFormWrapper"
import SignUpVerification from "@/components/Blocks/DynamicContent/SignUpVerification"
import PreviousStays from "@/components/Blocks/DynamicContent/Stays/Previous"
import SoonestStays from "@/components/Blocks/DynamicContent/Stays/Soonest"
import UpcomingStays from "@/components/Blocks/DynamicContent/Stays/Upcoming"
@@ -51,6 +53,10 @@ export default async function DynamicContent({
return <PointsOverview {...dynamic_content} />
case DynamicContentEnum.Blocks.components.previous_stays:
return <PreviousStays {...dynamic_content} />
case DynamicContentEnum.Blocks.components.sign_up_form:
return <SignupFormWrapper dynamic_content={dynamic_content} />
case DynamicContentEnum.Blocks.components.sign_up_verification:
return <SignUpVerification dynamic_content={dynamic_content} />
case DynamicContentEnum.Blocks.components.soonest_stays:
return <SoonestStays {...dynamic_content} />
case DynamicContentEnum.Blocks.components.upcoming_stays:

View File

@@ -18,11 +18,13 @@ export default function LoginButton({
trackingId,
children,
color = "black",
variant = "default",
}: PropsWithChildren<{
className: string
trackingId: string
position: TrackingPosition
color?: LinkProps["color"]
variant?: "default" | "signupVerification"
}>) {
const lang = useLang()
const pathName = useLazyPathname()
@@ -49,6 +51,7 @@ export default function LoginButton({
color={color}
href={href}
prefetch={false}
variant={variant}
>
{children}
</Link>

View File

@@ -7,7 +7,6 @@ import { dt } from "@/lib/dt"
import DatePicker from "@/components/DatePicker"
import { SearchIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Input from "./Input"

View File

@@ -2,8 +2,10 @@
align-items: center;
display: grid;
margin: 0 auto;
max-width: var(--max-width-navigation);
width: 100%;
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.form {
@@ -32,7 +34,8 @@
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
var(--Spacing-x-one-and-half) var(--Spacing-x1);
}
.full {
padding: var(--Spacing-x1) var(--Spacing-x5);
padding: var(--Spacing-x1) 0;
}
}

View File

@@ -69,6 +69,7 @@ export default function Form({ user }: EditFormProps) {
retypeNewPassword: "",
},
mode: "all",
criteriaMode: "all",
resolver: zodResolver(editProfileSchema),
reValidateMode: "onChange",
})

View File

@@ -1,6 +1,6 @@
import { z } from "zod"
import { Key } from "@/components/TempDesignSystem/Form/NewPassword/newPassword"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
const countryRequiredMsg = "Country is required"
@@ -26,7 +26,7 @@ export const editProfileSchema = z
),
password: z.string().optional(),
newPassword: z.string().optional(),
newPassword: passwordValidator(),
retypeNewPassword: z.string().optional(),
})
.superRefine((data, ctx) => {
@@ -55,29 +55,6 @@ export const editProfileSchema = z
}
}
if (data.newPassword) {
const msgs = []
if (data.newPassword.length < 10 || data.newPassword.length > 40) {
msgs.push(Key.CHAR_LENGTH)
}
if (!data.newPassword.match(/[A-Z]/g)) {
msgs.push(Key.UPPERCASE)
}
if (!data.newPassword.match(/[0-9]/g)) {
msgs.push(Key.NUM)
}
if (!data.newPassword.match(/[^A-Za-z0-9]/g)) {
msgs.push(Key.SPECIAL_CHAR)
}
if (msgs.length) {
ctx.addIssue({
code: "custom",
message: msgs.join(","),
path: ["newPassword"],
})
}
}
if (data.newPassword && !data.retypeNewPassword) {
ctx.addIssue({
code: "custom",

View File

@@ -0,0 +1,49 @@
.form {
display: grid;
gap: var(--Spacing-x5);
grid-area: form;
}
.title {
grid-area: title;
}
.formWrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.userInfo,
.password,
.terms {
align-self: flex-start;
display: grid;
gap: var(--Spacing-x2);
}
.container {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.nameInputs {
display: grid;
gap: var(--Spacing-x2);
}
.dateField {
display: grid;
gap: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {
.formWrapper {
gap: var(--Spacing-x5);
}
.nameInputs {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,185 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import { signupTerms } from "@/constants/routes/signup"
import { registerUser } from "@/actions/registerUser"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import DateSelect from "@/components/TempDesignSystem/Form/Date"
import Input from "@/components/TempDesignSystem/Form/Input"
import NewPassword from "@/components/TempDesignSystem/Form/NewPassword"
import Phone from "@/components/TempDesignSystem/Form/Phone"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import { toast } from "@/components/TempDesignSystem/Toasts"
import useLang from "@/hooks/useLang"
import { RegisterSchema, registerSchema } from "./schema"
import styles from "./form.module.css"
import type { RegisterFormProps } from "@/types/components/form/registerForm"
export default function Form({ link, subtitle, title }: RegisterFormProps) {
const intl = useIntl()
const lang = useLang()
const methods = useForm<RegisterSchema>({
defaultValues: {
firstName: "",
lastName: "",
email: "",
phoneNumber: "",
dateOfBirth: "",
address: {
countryCode: "",
zipCode: "",
},
password: "",
termsAccepted: false,
},
mode: "all",
criteriaMode: "all",
resolver: zodResolver(registerSchema),
reValidateMode: "onChange",
})
const country = intl.formatMessage({ id: "Country" })
const email = `${intl.formatMessage({ id: "Email" })} ${intl.formatMessage({ id: "Address" }).toLowerCase()}`
const phoneNumber = intl.formatMessage({ id: "Phone number" })
const zipCode = intl.formatMessage({ id: "Zip code" })
async function handleSubmit(data: RegisterSchema) {
try {
const result = await registerUser(data)
if (result && !result.success) {
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
}
} catch (error) {
// The server-side redirect will throw an error, which we can ignore
// as it's handled by Next.js.
if (error instanceof Error && error.message.includes("NEXT_REDIRECT")) {
return
}
toast.error(intl.formatMessage({ id: "Something went wrong!" }))
}
}
return (
<section className={styles.formWrapper}>
<Title as="h3">{title}</Title>
<FormProvider {...methods}>
<form
className={styles.form}
id="register"
/**
* Ignoring since ts doesn't recognize that tRPC
* parses FormData before reaching the route
* @ts-ignore */
action={registerUser}
onSubmit={methods.handleSubmit(handleSubmit)}
>
<section className={styles.userInfo}>
<div className={styles.container}>
<header>
<Subtitle type="two">
{intl.formatMessage({ id: "Contact information" })}
</Subtitle>
</header>
<div className={styles.nameInputs}>
<Input
label={"firstName"}
name="firstName"
registerOptions={{ required: true }}
/>
<Input
label={"lastName"}
name="lastName"
registerOptions={{ required: true }}
/>
</div>
</div>
<div className={styles.dateField}>
<header>
<Caption textTransform="bold">
{intl.formatMessage({ id: "Birth date" })}
</Caption>
</header>
<DateSelect
name="dateOfBirth"
registerOptions={{ required: true }}
/>
</div>
<div className={styles.container}>
<Input
label={zipCode}
name="address.zipCode"
registerOptions={{ required: true }}
/>
<CountrySelect
label={country}
name="address.countryCode"
registerOptions={{ required: true }}
/>
</div>
<Input
label={email}
name="email"
registerOptions={{ required: true }}
type="email"
/>
<Phone label={phoneNumber} name="phoneNumber" />
</section>
<section className={styles.password}>
<header>
<Subtitle type="two">
{intl.formatMessage({ id: "Password" })}
</Subtitle>
</header>
<NewPassword
name="password"
placeholder="Password"
label={intl.formatMessage({ id: "Password" })}
/>
</section>
<section className={styles.terms}>
<header>
<Subtitle type="two">
{intl.formatMessage({ id: "Terms and conditions" })}
</Subtitle>
</header>
<Checkbox name="termsAccepted" registerOptions={{ required: true }}>
<Body>
{intl.formatMessage({
id: "Yes, I accept the Terms and conditions for Scandic Friends and understand that Scandic will process my personal data in accordance with",
})}{" "}
<Link
variant="underscored"
color="peach80"
target="_blank"
href={signupTerms[lang]}
>
{intl.formatMessage({ id: "Scandic's Privacy Policy." })}
</Link>
</Body>
</Checkbox>
</section>
<Button
type="submit"
intent="primary"
disabled={methods.formState.isSubmitting}
data-testid="submit"
>
{intl.formatMessage({ id: "Sign up to Scandic Friends" })}
</Button>
</form>
</FormProvider>
</section>
)
}

View File

@@ -0,0 +1,35 @@
import { z } from "zod"
import { passwordValidator } from "@/utils/passwordValidator"
import { phoneValidator } from "@/utils/phoneValidator"
export const registerSchema = z.object({
firstName: z
.string()
.max(250)
.refine((value) => value.trim().length > 0, {
message: "First name is required",
}),
lastName: z
.string()
.max(250)
.refine((value) => value.trim().length > 0, {
message: "Last name is required",
}),
email: z.string().max(250).email(),
phoneNumber: phoneValidator(
"Phone is required",
"Please enter a valid phone number"
),
dateOfBirth: z.string().min(1),
address: z.object({
countryCode: z.string(),
zipCode: z.string().min(1),
}),
password: passwordValidator("Password is required"),
termsAccepted: z.boolean().refine((value) => value === true, {
message: "You must accept the terms and conditions",
}),
})
export type RegisterSchema = z.infer<typeof registerSchema>

View File

@@ -0,0 +1,165 @@
import { dt } from "@/lib/dt"
import { ArrowRightIcon, ChevronRightSmallIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./summary.module.css"
// TEMP
const rooms = [
{
adults: 1,
type: "Cozy cabin",
},
]
export default async function Summary() {
const intl = await getIntl()
const lang = getLang()
const fromDate = dt().locale(lang).format("ddd, D MMM")
const toDate = dt().add(1, "day").locale(lang).format("ddd, D MMM")
const diff = dt(toDate).diff(fromDate, "days")
const totalAdults = rooms.reduce((total, room) => total + room.adults, 0)
const adults = intl.formatMessage(
{ id: "booking.adults" },
{ totalAdults: totalAdults }
)
const nights = intl.formatMessage(
{ id: "booking.nights" },
{ totalNights: diff }
)
const addOns = [
{
price: intl.formatMessage({ id: "Included" }),
title: intl.formatMessage({ id: "King bed" }),
},
{
price: intl.formatMessage({ id: "Included" }),
title: intl.formatMessage({ id: "Breakfast buffet" }),
},
]
const mappedRooms = Array.from(
rooms
.reduce((acc, room) => {
const currentRoom = acc.get(room.type)
acc.set(room.type, {
total: currentRoom ? currentRoom.total + 1 : 1,
type: room.type,
})
return acc
}, new Map())
.values()
)
return (
<section className={styles.summary}>
<header>
<Body textTransform="bold">
{mappedRooms.map(
(room, idx) =>
`${room.total} x ${room.type}${mappedRooms.length > 1 && idx + 1 !== mappedRooms.length ? ", " : ""}`
)}
</Body>
<Body className={styles.date} color="textMediumContrast">
{fromDate}
<ArrowRightIcon color="uiTextMediumContrast" height={15} width={15} />
{toDate}
</Body>
<Link
className={styles.link}
color="baseButtonTextOnFillNormal"
href="#"
variant="icon"
>
{intl.formatMessage({ id: "See room details" })}
<ChevronRightSmallIcon
color="baseButtonTextOnFillNormal"
height={20}
width={20}
/>
</Link>
</header>
<Divider color="primaryLightSubtle" />
<div className={styles.addOns}>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{`${nights}, ${adults}`}
</Caption>
<Caption color="uiTextHighContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4536", currency: "SEK" }
)}
</Caption>
</div>
{addOns.map((addOn) => (
<div className={styles.entry} key={addOn.title}>
<Caption color="uiTextMediumContrast">{addOn.title}</Caption>
<Caption color="uiTextHighContrast">{addOn.price}</Caption>
</div>
))}
</div>
<Divider color="primaryLightSubtle" />
<div className={styles.total}>
<div>
<div className={styles.entry}>
<Body textTransform="bold">
{intl.formatMessage({ id: "Total incl VAT" })}
</Body>
<Body textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4686", currency: "SEK" }
)}
</Body>
</div>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "455", currency: "EUR" }
)}
</Caption>
</div>
</div>
<div>
<div className={styles.entry}>
<Body color="red" textTransform="bold">
{intl.formatMessage({ id: "Member price" })}
</Body>
<Body color="red" textTransform="bold">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "4219", currency: "SEK" }
)}
</Body>
</div>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{ amount: "412", currency: "EUR" }
)}
</Caption>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,38 @@
.summary {
background-color: var(--Main-Grey-White);
border: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
border-radius: var(--Corner-radius-Large);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2);
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: flex-start;
}
.link {
margin-top: var(--Spacing-x1);
}
.addOns {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.total {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}

View File

@@ -36,15 +36,18 @@
@media (min-width: 768px) {
.hotelSelectionHeader {
padding: var(--Spacing-x4) var(--Spacing-x5);
padding: var(--Spacing-x4) 0;
}
.hotelSelectionHeaderWrapper {
flex-direction: row;
gap: var(--Spacing-x6);
max-width: var(--max-width-navigation);
margin: 0 auto;
width: 100%;
/* simulates padding on viewport smaller than --max-width-navigation */
width: min(
calc(100dvw - (var(--Spacing-x2) * 2)),
var(--max-width-navigation)
);
}
.titleContainer > h1 {

View File

@@ -1,6 +0,0 @@
"use client"
import styles from "./summary.module.css"
export default function Summary() {
return <div className={styles.wrapper}>Summary TBI</div>
}

View File

@@ -1,2 +0,0 @@
.wrapper {
}

View File

@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function EyeHideIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="mask0_69_3263"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_69_3263)">
<path
d="M15.9625 13.3L14.6 11.9375C14.7834 11.0335 14.5521 10.2616 13.9063 9.62196C13.2604 8.98231 12.4792 8.74165 11.5625 8.89999L10.2 7.53749C10.4834 7.40832 10.775 7.31145 11.075 7.24686C11.375 7.18228 11.6834 7.14999 12 7.14999C13.2084 7.14999 14.2354 7.5729 15.0813 8.41874C15.9271 9.26457 16.35 10.2917 16.35 11.5C16.35 11.8167 16.3177 12.125 16.2531 12.425C16.1886 12.725 16.0917 13.0167 15.9625 13.3ZM19.05 16.3625L17.7125 15.05C18.3485 14.5667 18.9176 14.0292 19.4197 13.4375C19.9218 12.8458 20.3403 12.2 20.675 11.5C19.8584 9.83332 18.6709 8.51249 17.1125 7.53749C15.5542 6.56249 13.85 6.07499 12 6.07499C11.5167 6.07499 11.0396 6.1104 10.5688 6.18124C10.0979 6.25207 9.62919 6.35832 9.16252 6.49999L7.70002 5.03749C8.39169 4.75415 9.09591 4.54374 9.81267 4.40624C10.5294 4.26874 11.2585 4.19999 12 4.19999C14.2167 4.19999 16.25 4.78749 18.1 5.96249C19.95 7.13749 21.3625 8.71665 22.3375 10.7C22.4042 10.825 22.4521 10.9542 22.4813 11.0877C22.5104 11.2212 22.525 11.3586 22.525 11.5C22.525 11.6413 22.5125 11.7788 22.4875 11.9122C22.4625 12.0457 22.4209 12.1792 22.3625 12.3125C21.9875 13.1208 21.5188 13.8646 20.9563 14.5437C20.3938 15.2229 19.7584 15.8292 19.05 16.3625ZM12 18.8C9.81669 18.8 7.82502 18.2083 6.02502 17.025C4.22502 15.8417 2.79942 14.2901 1.74822 12.3702C1.66609 12.2401 1.60836 12.1013 1.57502 11.9539C1.54169 11.8064 1.52502 11.6552 1.52502 11.5C1.52502 11.3423 1.54077 11.1926 1.57225 11.0507C1.60373 10.9088 1.65882 10.7669 1.73752 10.625C2.07919 9.96665 2.46877 9.33332 2.90627 8.72499C3.34377 8.11665 3.84169 7.56665 4.40002 7.07499L2.32502 4.92499C2.15002 4.74054 2.06669 4.52151 2.07502 4.26791C2.08336 4.0143 2.17502 3.79999 2.35002 3.62499C2.52502 3.44999 2.74169 3.36249 3.00002 3.36249C3.25836 3.36249 3.47502 3.44999 3.65002 3.62499L20.3375 20.3125C20.5209 20.4958 20.6125 20.7167 20.6125 20.975C20.6125 21.2333 20.5167 21.4542 20.325 21.6375C20.1417 21.8125 19.9229 21.8979 19.6688 21.8937C19.4146 21.8896 19.2 21.8 19.025 21.625L15.5875 18.2375C15.0042 18.4208 14.4139 18.5604 13.8166 18.6562C13.2194 18.7521 12.6139 18.8 12 18.8ZM5.72502 8.37499C5.22502 8.80832 4.77086 9.2854 4.36252 9.80624C3.95419 10.3271 3.60836 10.8917 3.32502 11.5C4.14169 13.1667 5.32919 14.4875 6.88752 15.4625C8.44586 16.4375 10.15 16.925 12 16.925C12.3535 16.925 12.6981 16.9042 13.0339 16.8625C13.3696 16.8208 13.7084 16.7667 14.05 16.7L13.1 15.7C12.9167 15.75 12.7363 15.7875 12.5589 15.8125C12.3815 15.8375 12.1952 15.85 12 15.85C10.7917 15.85 9.76461 15.4271 8.91877 14.5812C8.07294 13.7354 7.65002 12.7083 7.65002 11.5C7.65002 11.3167 7.66252 11.1375 7.68752 10.9625C7.71252 10.7875 7.75002 10.6083 7.80002 10.425L5.72502 8.37499Z"
fill="#26201E"
/>
</g>
</svg>
)
}

View File

@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function EyeShowIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
id="mask0_69_3264"
style={{ maskType: "alpha" }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24"
>
<rect width="24" height="24" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_69_3264)">
<path
d="M12 15.85C13.2083 15.85 14.2354 15.4271 15.0812 14.5813C15.9271 13.7354 16.35 12.7083 16.35 11.5C16.35 10.2917 15.9271 9.2646 15.0812 8.41876C14.2354 7.57293 13.2083 7.15001 12 7.15001C10.7916 7.15001 9.76456 7.57293 8.91873 8.41876C8.07289 9.2646 7.64998 10.2917 7.64998 11.5C7.64998 12.7083 8.07289 13.7354 8.91873 14.5813C9.76456 15.4271 10.7916 15.85 12 15.85ZM12.0029 14.15C11.2676 14.15 10.6416 13.8927 10.125 13.378C9.60831 12.8632 9.34998 12.2382 9.34998 11.503C9.34998 10.7677 9.60733 10.1417 10.122 9.62501C10.6367 9.10835 11.2617 8.85001 11.997 8.85001C12.7323 8.85001 13.3583 9.10736 13.875 9.62206C14.3916 10.1368 14.65 10.7618 14.65 11.4971C14.65 12.2324 14.3926 12.8583 13.8779 13.375C13.3632 13.8917 12.7382 14.15 12.0029 14.15ZM12.0019 18.8C9.8256 18.8 7.83956 18.2125 6.04373 17.0375C4.24789 15.8625 2.82498 14.3167 1.77498 12.4C1.69164 12.2583 1.63123 12.1124 1.59373 11.9622C1.55623 11.812 1.53748 11.6578 1.53748 11.4997C1.53748 11.3416 1.55623 11.1875 1.59373 11.0375C1.63123 10.8875 1.69164 10.7417 1.77498 10.6C2.82498 8.68335 4.24727 7.13751 6.04185 5.96251C7.83645 4.78751 9.82187 4.20001 11.9981 4.20001C14.1744 4.20001 16.1604 4.78751 17.9562 5.96251C19.7521 7.13751 21.175 8.68335 22.225 10.6C22.3083 10.7417 22.3687 10.8876 22.4062 11.0378C22.4437 11.1881 22.4625 11.3422 22.4625 11.5003C22.4625 11.6585 22.4437 11.8125 22.4062 11.9625C22.3687 12.1125 22.3083 12.2583 22.225 12.4C21.175 14.3167 19.7527 15.8625 17.9581 17.0375C16.1635 18.2125 14.1781 18.8 12.0019 18.8ZM11.9999 16.925C13.8583 16.925 15.5646 16.4375 17.1187 15.4625C18.6729 14.4875 19.8583 13.1667 20.675 11.5C19.8583 9.83335 18.6729 8.51251 17.1188 7.53751C15.5647 6.56251 13.8584 6.07501 12 6.07501C10.1417 6.07501 8.43539 6.56251 6.88123 7.53751C5.32706 8.51251 4.14164 9.83335 3.32498 11.5C4.14164 13.1667 5.32704 14.4875 6.88118 15.4625C8.43529 16.4375 10.1415 16.925 11.9999 16.925Z"
fill="#26201E"
/>
</g>
</svg>
)
}

View File

@@ -29,6 +29,8 @@ import {
DoorOpenIcon,
ElectricBikeIcon,
EmailIcon,
EyeHideIcon,
EyeShowIcon,
FitnessIcon,
GiftIcon,
GlobeIcon,
@@ -114,6 +116,10 @@ export function getIconByIconName(icon?: IconName): FC<IconProps> | null {
return ElectricBikeIcon
case IconName.Email:
return EmailIcon
case IconName.EyeHide:
return EyeHideIcon
case IconName.EyeShow:
return EyeShowIcon
case IconName.Facebook:
return FacebookIcon
case IconName.Fitness:

View File

@@ -71,3 +71,8 @@
.baseButtonTertiaryOnFillNormal * {
fill: var(--Base-Button-Tertiary-On-Fill-Normal);
}
.baseButtonTextOnFillNormal,
.baseButtonTextOnFillNormal * {
fill: var(--Base-Button-Text-On-Fill-Normal);
}

View File

@@ -28,6 +28,8 @@ export { default as EditIcon } from "./Edit"
export { default as ElectricBikeIcon } from "./ElectricBike"
export { default as EmailIcon } from "./Email"
export { default as ErrorCircleIcon } from "./ErrorCircle"
export { default as EyeHideIcon } from "./EyeHide"
export { default as EyeShowIcon } from "./EyeShow"
export { default as FitnessIcon } from "./Fitness"
export { default as GiftIcon } from "./Gift"
export { default as GlobeIcon } from "./Globe"

View File

@@ -6,6 +6,7 @@ const config = {
variants: {
color: {
baseButtonTertiaryOnFillNormal: styles.baseButtonTertiaryOnFillNormal,
baseButtonTextOnFillNormal: styles.baseButtonTextOnFillNormal,
baseIconLowContrast: styles.baseIconLowContrast,
black: styles.black,
blue: styles.blue,

View File

@@ -0,0 +1,39 @@
.container {
display: flex;
flex-direction: column;
color: var(--text-color);
}
.container[data-selected] .checkbox {
border: none;
background: var(--UI-Input-Controls-Fill-Selected);
}
.checkboxContainer {
display: flex;
align-items: flex-start;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
flex-grow: 1;
width: 24px;
height: 24px;
min-width: 24px;
border: 2px solid var(--UI-Input-Controls-Border-Normal);
border-radius: 4px;
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
transition: all 200ms;
forced-color-adjust: none;
}
.error {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;
}

View File

@@ -0,0 +1,7 @@
import { RegisterOptions } from "react-hook-form"
export interface CheckboxProps
extends React.InputHTMLAttributes<HTMLInputElement> {
name: string
registerOptions?: RegisterOptions
}

View File

@@ -0,0 +1,51 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import { useController, useFormContext } from "react-hook-form"
import { InfoCircleIcon } from "@/components/Icons"
import CheckIcon from "@/components/Icons/Check"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { CheckboxProps } from "./checkbox"
import styles from "./checkbox.module.css"
export default function Checkbox({
name,
children,
registerOptions,
}: React.PropsWithChildren<CheckboxProps>) {
const { control } = useFormContext()
const { field, fieldState } = useController({
control,
name,
rules: registerOptions,
})
return (
<AriaCheckbox
className={styles.container}
isSelected={field.value}
onChange={field.onChange}
data-testid={name}
>
{({ isSelected }) => (
<>
<div className={styles.checkboxContainer}>
<div className={styles.checkbox}>
{isSelected && <CheckIcon color="white" />}
</div>
{children}
</div>
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</>
)}
</AriaCheckbox>
)
}

View File

@@ -68,6 +68,7 @@ export default function CountrySelect({
onSelectionChange={handleChange}
ref={field.ref}
selectedKey={field.value}
data-testid={name}
>
<div className={styles.comboBoxContainer}>
<Label

View File

@@ -1,5 +1,6 @@
"use client"
import { parseDate } from "@internationalized/date"
import { useState } from "react"
import { DateInput, DatePicker, Group } from "react-aria-components"
import { useController, useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
@@ -18,7 +19,7 @@ import type { Key } from "react-aria-components"
import type { DateProps } from "./date"
export default function DateSelect({ name, registerOptions = {} }: DateProps) {
const { formatMessage } = useIntl()
const intl = useIntl()
const d = useWatch({ name })
const { control, setValue } = useFormContext()
const { field } = useController({
@@ -26,6 +27,19 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
name,
rules: registerOptions,
})
const [dateSegments, setDateSegment] = useState<{
year: number | null
month: number | null
date: number | null
daysInMonth: number
}>({
year: null,
month: null,
date: null,
daysInMonth: 31,
})
const currentYear = new Date().getFullYear()
const months = rangeArray(1, 12).map((month) => ({
value: month,
@@ -41,17 +55,38 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
* must subtract by 1 to get the selected month
*/
return (select: Key) => {
if (selector === DateName.month) {
select = Number(select) - 1
const value =
selector === DateName.month ? Number(select) - 1 : Number(select)
const newSegments = { ...dateSegments, [selector]: value }
/**
* Update daysInMonth when year or month changes
* to ensure the user can't select a date that doesn't exist.
*/
if (selector === DateName.year || selector === DateName.month) {
const year = selector === DateName.year ? value : newSegments.year
const month = selector === DateName.month ? value : newSegments.month
if (year !== null && month !== null) {
newSegments.daysInMonth = dt().year(year).month(month).daysInMonth()
}
}
const newDate = dt(d).set(selector, Number(select))
setValue(name, newDate.format("YYYY-MM-DD"))
if (Object.values(newSegments).every((val) => val !== null)) {
const newDate = dt()
.utc()
.set("year", newSegments.year!)
.set("month", newSegments.month!)
.set("date", Math.min(newSegments.date!, newSegments.daysInMonth))
setValue(name, newDate.format("YYYY-MM-DD"))
}
setDateSegment(newSegments)
}
}
const dayLabel = formatMessage({ id: "Day" })
const monthLabel = formatMessage({ id: "Month" })
const yearLabel = formatMessage({ id: "Year" })
const dayLabel = intl.formatMessage({ id: "Day" })
const monthLabel = intl.formatMessage({ id: "Month" })
const yearLabel = intl.formatMessage({ id: "Year" })
let dateValue = null
try {
@@ -60,35 +95,30 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
* date, but we can't check isNan since
* we recieve the date as "1999-01-01"
*/
dateValue = parseDate(d)
dateValue = dt(d).isValid() ? parseDate(d) : null
} catch (error) {
console.error(error)
}
return (
<DatePicker
aria-label={formatMessage({ id: "Select date of birth" })}
granularity="day"
aria-label={intl.formatMessage({ id: "Select date of birth" })}
isRequired={!!registerOptions.required}
name={name}
ref={field.ref}
value={dateValue}
data-testid={name}
>
<Group>
<DateInput className={styles.container}>
{(segment) => {
switch (segment.type) {
case "day":
let days = []
if (segment.maxValue && segment.minValue) {
days = rangeArray(segment.minValue, segment.maxValue).map(
(day) => ({ value: day, label: `${day}` })
)
} else {
days = Array.from(Array(segment.maxValue).keys()).map(
(i) => ({ value: i + 1, label: `${i + 1}` })
)
}
const maxDays = dateSegments.daysInMonth
const days = rangeArray(1, maxDays).map((day) => ({
value: day,
label: `${day}`,
}))
return (
<div className={styles.day}>
<Select
@@ -100,7 +130,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
placeholder="DD"
required
tabIndex={3}
value={segment.value}
defaultValue={
segment.isPlaceholder ? undefined : segment.value
}
/>
</div>
)
@@ -116,7 +148,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
placeholder="MM"
required
tabIndex={2}
value={segment.value}
defaultValue={
segment.isPlaceholder ? undefined : segment.value
}
/>
</div>
)
@@ -132,7 +166,9 @@ export default function DateSelect({ name, registerOptions = {} }: DateProps) {
placeholder="YYYY"
required
tabIndex={1}
value={segment.value}
defaultValue={
segment.isPlaceholder ? undefined : segment.value
}
/>
</div>
)

View File

@@ -1,33 +1,63 @@
"use client"
import { useState } from "react"
import { Text, TextField } from "react-aria-components"
import { Controller, useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { CheckIcon, CloseIcon } from "@/components/Icons"
import Error from "@/components/TempDesignSystem/Form/ErrorMessage/Error"
import {
CheckIcon,
CloseIcon,
EyeHideIcon,
EyeShowIcon,
InfoCircleIcon,
} from "@/components/Icons"
import AriaInputWithLabel from "@/components/TempDesignSystem/Form/Input/AriaInputWithLabel"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { passwordValidators } from "@/utils/passwordValidator"
import { type IconProps, Key, type NewPasswordProps } from "./newPassword"
import Button from "../../Button"
import { IconProps, type NewPasswordProps } from "./newPassword"
import styles from "./newPassword.module.css"
import { PasswordValidatorKey } from "@/types/components/form/newPassword"
export default function NewPassword({
name = "newPassword",
"aria-label": ariaLabel,
disabled = false,
placeholder = "",
registerOptions = {},
label,
}: NewPasswordProps) {
const { control } = useFormContext()
const { formatMessage } = useIntl()
const intl = useIntl()
const [isPasswordVisible, setPasswordVisible] = useState(false)
function getErrorMessage(key: PasswordValidatorKey) {
switch (key) {
case "length":
return `10 ${intl.formatMessage({ id: "to" })} 40 ${intl.formatMessage({ id: "characters" })}`
case "hasUppercase":
return `1 ${intl.formatMessage({ id: "uppercase letter" })}`
case "hasLowercase":
return `1 ${intl.formatMessage({ id: "lowercase letter" })}`
case "hasNumber":
return `1 ${intl.formatMessage({ id: "number" })}`
case "hasSpecialChar":
return `1 ${intl.formatMessage({ id: "special character" })}`
}
}
return (
<Controller
disabled={disabled}
control={control}
name="newPassword"
name={name}
rules={registerOptions}
render={({ field, fieldState }) => {
const messages = (fieldState.error?.message?.split(",") ?? []) as Key[]
render={({ field, fieldState, formState }) => {
const errors = Object.values(formState.errors[name]?.types ?? []).flat()
return (
<TextField
aria-label={ariaLabel}
@@ -39,51 +69,47 @@ export default function NewPassword({
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
type="password"
type={isPasswordVisible ? "text" : "password"}
>
<AriaInputWithLabel
{...field}
aria-labelledby={field.name}
id={field.name}
label={formatMessage({ id: "New password" })}
placeholder={placeholder}
type="password"
/>
<div className={styles.inputWrapper}>
<AriaInputWithLabel
{...field}
aria-labelledby={field.name}
id={field.name}
label={intl.formatMessage({ id: "New password" })}
placeholder={placeholder}
type={isPasswordVisible ? "text" : "password"}
/>
<Button
className={styles.eyeIcon}
type="button"
variant="icon"
size="small"
intent="tertiary"
onClick={() => setPasswordVisible(!isPasswordVisible)}
>
{isPasswordVisible ? <EyeHideIcon /> : <EyeShowIcon />}
</Button>
</div>
{field.value ? (
<div className={styles.errors}>
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<Icon matcher={Key.CHAR_LENGTH} messages={messages} />
10 {formatMessage({ id: "to" })} 40{" "}
{formatMessage({ id: "characters" })}
</Text>
</Caption>
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<Icon matcher={Key.UPPERCASE} messages={messages} />1{" "}
{formatMessage({ id: "uppercase letter" })}
</Text>
</Caption>
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<Icon matcher={Key.NUM} messages={messages} />1{" "}
{formatMessage({ id: "number" })}
</Text>
</Caption>
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<Icon matcher={Key.SPECIAL_CHAR} messages={messages} />1{" "}
{formatMessage({ id: "special character" })}
</Text>
</Caption>
{Object.entries(passwordValidators).map(
([key, { message }]) => (
<Caption asChild color="black" key={key}>
<Text className={styles.helpText} slot="description">
<Icon errorMessage={message} errors={errors} />
{getErrorMessage(key as PasswordValidatorKey)}
</Text>
</Caption>
)
)}
</div>
) : null}
{!field.value && fieldState.error ? (
<Error>
<Text className={styles.helpText} slot="description">
{fieldState.error.message}
</Text>
</Error>
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</TextField>
)
@@ -92,8 +118,8 @@ export default function NewPassword({
)
}
function Icon({ matcher, messages }: IconProps) {
return messages.includes(matcher) ? (
function Icon({ errorMessage, errors }: IconProps) {
return errors.includes(errorMessage) ? (
<CloseIcon color="red" height={20} width={20} />
) : (
<CheckIcon color="green" height={20} width={20} />

View File

@@ -1,12 +1,88 @@
.container {
align-content: center;
background-color: var(--Main-Grey-White);
border-color: var(--Scandic-Beige-40);
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Medium);
display: grid;
height: 60px;
padding: var(--Spacing-x1) var(--Spacing-x2);
transition: border-color 200ms ease;
position: relative;
}
.container:has(.input:active, .input:focus) {
border-color: var(--Scandic-Blue-90);
}
.container:has(.input:disabled) {
background-color: var(--Main-Grey-10);
border: none;
color: var(--Main-Grey-40);
}
.container:has(.input[data-invalid="true"], .input[aria-invalid="true"]) {
border-color: var(--Scandic-Red-60);
}
.input {
background: none;
border: none;
color: var(--Main-Grey-100);
height: 18px;
margin: 0;
order: 2;
overflow: visible;
padding: 0;
}
.input:not(:active, :focus):placeholder-shown {
height: 0px;
transition: height 150ms ease;
}
.input:focus,
.input:focus:placeholder-shown,
.input:active,
.input:active:placeholder-shown {
height: 18px;
transition: height 150ms ease;
outline: none;
}
.input:disabled {
color: var(--Main-Grey-40);
}
.helpText {
align-items: flex-start;
display: flex;
gap: var(--Spacing-x-half);
}
.error {
align-items: center;
color: var(--Scandic-Red-60);
display: flex;
gap: var(--Spacing-x-half);
margin: var(--Spacing-x1) 0 0;
}
.errors {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x-one-and-half) var(--Spacing-x1);
padding-top: var(--Spacing-x1);
}
.eyeIcon {
position: absolute;
right: var(--Spacing-x2);
top: 50%;
transform: translateY(-50%);
}
.inputWrapper {
position: relative;
}

View File

@@ -1,12 +1,5 @@
import type { RegisterOptions } from "react-hook-form"
export enum Key {
CHAR_LENGTH = "CHAR_LENGTH",
NUM = "NUM",
SPECIAL_CHAR = "SPECIAL_CHAR",
UPPERCASE = "UPPERCASE",
}
export interface NewPasswordProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
@@ -14,6 +7,6 @@ export interface NewPasswordProps
}
export interface IconProps {
matcher: Key
messages: Key[]
errorMessage: string
errors: string[]
}

View File

@@ -85,6 +85,7 @@ export default function Phone({
className={styles.select}
tabIndex={0}
type="button"
data-testid="country-selector"
>
<Label required={!!registerOptions.required} size="small">
{formatMessage({ id: "Country code" })}

View File

@@ -25,11 +25,13 @@ export default function Select({
disabled={field.disabled}
items={items}
label={label}
aria-label={label}
name={field.name}
onBlur={field.onBlur}
onSelect={field.onChange}
placeholder={placeholder}
value={field.value}
data-testid={name}
/>
)
}

View File

@@ -107,6 +107,10 @@
font-weight: 600;
}
.baseButtonTextOnFillNormal {
color: var(--Base-Button-Text-On-Fill-Normal);
}
.black {
color: #000;
}
@@ -218,3 +222,15 @@
color: var(--Base-Text-High-contrast);
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.signupVerification {
background-color: var(--Base-Button-Primary-Fill-Normal);
color: var(--Base-Button-Inverted-Fill-Normal);
cursor: pointer;
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
border-radius: var(--Corner-radius-Rounded);
text-decoration: none;
display: inline-block;
text-align: center;
transition: background-color 0.3s ease;
}

View File

@@ -8,6 +8,7 @@ export const linkVariants = cva(styles.link, {
true: styles.active,
},
color: {
baseButtonTextOnFillNormal: styles.baseButtonTextOnFillNormal,
black: styles.black,
burgundy: styles.burgundy,
none: "",
@@ -35,6 +36,7 @@ export const linkVariants = cva(styles.link, {
shortcut: styles.shortcut,
sidebar: styles.sidebar,
tab: styles.tab,
signupVerification: styles.signupVerification,
},
},
defaultVariants: {

View File

@@ -59,7 +59,7 @@ export default function Select({
selectedKey={value as Key}
>
<Body asChild fontOnly>
<Button className={styles.input}>
<Button className={styles.input} data-testid={name}>
<div className={styles.inputContentWrapper} tabIndex={tabIndex}>
<Label required={required} size="small">
{label}
@@ -89,6 +89,7 @@ export default function Select({
className={styles.listBoxItem}
id={item.value}
key={item.label}
data-testid={item.label}
>
{item.label}
</ListBoxItem>