diff --git a/actions/registerUser.ts b/actions/registerUser.ts new file mode 100644 index 000000000..11afb22f4 --- /dev/null +++ b/actions/registerUser.ts @@ -0,0 +1,88 @@ +"use server" + +import { redirect } from "next/navigation" +import { z } from "zod" + +import { signupVerify } from "@/constants/routes/signup" +import * as api from "@/lib/api" +import { serviceServerActionProcedure } from "@/server/trpc" + +import { registerSchema } from "@/components/Forms/Register/schema" +import { passwordValidator } from "@/utils/passwordValidator" +import { phoneValidator } from "@/utils/phoneValidator" + +const registerUserPayload = z.object({ + language: z.string(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + phoneNumber: phoneValidator("Phone is required"), + dateOfBirth: z.string(), + address: z.object({ + city: z.string().default(""), + country: z.string().default(""), + countryCode: z.string().default(""), + zipCode: z.string().default(""), + streetAddress: z.string().default(""), + }), + password: passwordValidator("Password is required"), +}) + +export const registerUser = serviceServerActionProcedure + .input(registerSchema) + .mutation(async function ({ ctx, input }) { + const payload = { + ...input, + language: ctx.lang, + phoneNumber: input.phoneNumber.replace(/\s+/g, ""), + } + + const parsedPayload = registerUserPayload.safeParse(payload) + if (!parsedPayload.success) { + console.error( + "registerUser payload validation error", + JSON.stringify({ + query: input, + error: parsedPayload.error, + }) + ) + + return { success: false, error: "Validation error" } + } + + let apiResponse + try { + apiResponse = await api.post(api.endpoints.v1.profile, { + body: parsedPayload.data, + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + }) + } catch (error) { + console.error("Unexpected error", error) + return { success: false, error: "Unexpected error" } + } + + if (!apiResponse.ok) { + const text = await apiResponse.text() + console.error( + "registerUser api error", + JSON.stringify({ + query: input, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + error: text, + }, + }) + ) + return { success: false, error: "API error" } + } + + const json = await apiResponse.json() + console.log("registerUser: json", json) + + // Note: The redirect needs to be called after the try/catch block. + // See: https://nextjs.org/docs/app/api-reference/functions/redirect + redirect(signupVerify[ctx.lang]) + }) diff --git a/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css b/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css index af1b93963..9cfb8be93 100644 --- a/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css +++ b/app/[lang]/(live)/(public)/[contentType]/[uid]/layout.module.css @@ -5,6 +5,5 @@ gap: var(--Spacing-x3); grid-template-rows: auto 1fr; position: relative; - max-width: var(--max-width); margin: 0 auto; } diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.module.css b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.module.css index f40fca31d..4f337ccb2 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.module.css +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.module.css @@ -9,8 +9,12 @@ grid-template-columns: 1fr 340px; grid-template-rows: auto 1fr; margin: var(--Spacing-x5) auto 0; - max-width: var(--max-width-navigation); - padding: var(--Spacing-x6) var(--Spacing-x2) 0; + padding-top: var(--Spacing-x6); + /* simulates padding on viewport smaller than --max-width-navigation */ + width: min( + calc(100dvw - (var(--Spacing-x2) * 2)), + var(--max-width-navigation) + ); } .summary { diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx index c234123a4..8ced3a7ce 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/layout.tsx @@ -3,8 +3,9 @@ import { redirect } from "next/navigation" import { serverClient } from "@/lib/trpc/server" import SelectedRoom from "@/components/HotelReservation/EnterDetails/SelectedRoom" +import Summary from "@/components/HotelReservation/EnterDetails/Summary" import HotelSelectionHeader from "@/components/HotelReservation/HotelSelectionHeader" -import Summary from "@/components/HotelReservation/SelectRate/Summary" +import { setLang } from "@/i18n/serverContext" import styles from "./layout.module.css" @@ -14,6 +15,7 @@ export default async function StepLayout({ children, params, }: React.PropsWithChildren>) { + setLang(params.lang) const hotel = await serverClient().hotel.hotelData.get({ hotelId: "811", language: params.lang, diff --git a/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx index b853fc9bc..6f51260e4 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/[step]/page.tsx @@ -43,6 +43,8 @@ export default function StepPage({ language: params.lang, }) + const { data: userData } = trpc.user.getSafely.useQuery() + if (loadingHotel) { return } @@ -70,6 +72,11 @@ export default function StepPage({ } } + let user = null + if (userData && !("error" in userData)) { + user = userData + } + return (
-
+
+ + {intl.formatMessage({ id: "Proceed to login" })} + + + ) +} diff --git a/components/Blocks/DynamicContent/SignUpVerification/signUpVerification.module.css b/components/Blocks/DynamicContent/SignUpVerification/signUpVerification.module.css new file mode 100644 index 000000000..8e1a8e925 --- /dev/null +++ b/components/Blocks/DynamicContent/SignUpVerification/signUpVerification.module.css @@ -0,0 +1,6 @@ +.container { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: var(--Spacing-x3); +} diff --git a/components/Blocks/DynamicContent/SignupFormWrapper/index.tsx b/components/Blocks/DynamicContent/SignupFormWrapper/index.tsx new file mode 100644 index 000000000..4c39dafc5 --- /dev/null +++ b/components/Blocks/DynamicContent/SignupFormWrapper/index.tsx @@ -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
+} diff --git a/components/Blocks/DynamicContent/index.tsx b/components/Blocks/DynamicContent/index.tsx index bc024baeb..31ba4009a 100644 --- a/components/Blocks/DynamicContent/index.tsx +++ b/components/Blocks/DynamicContent/index.tsx @@ -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 case DynamicContentEnum.Blocks.components.previous_stays: return + case DynamicContentEnum.Blocks.components.sign_up_form: + return + case DynamicContentEnum.Blocks.components.sign_up_verification: + return case DynamicContentEnum.Blocks.components.soonest_stays: return case DynamicContentEnum.Blocks.components.upcoming_stays: diff --git a/components/Current/Header/LoginButton.tsx b/components/Current/Header/LoginButton.tsx index d74d63e80..475640879 100644 --- a/components/Current/Header/LoginButton.tsx +++ b/components/Current/Header/LoginButton.tsx @@ -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} diff --git a/components/Forms/BookingWidget/FormContent/index.tsx b/components/Forms/BookingWidget/FormContent/index.tsx index 8f3e8cbc4..c7f990c38 100644 --- a/components/Forms/BookingWidget/FormContent/index.tsx +++ b/components/Forms/BookingWidget/FormContent/index.tsx @@ -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" diff --git a/components/Forms/BookingWidget/form.module.css b/components/Forms/BookingWidget/form.module.css index 8514dffd7..22ef6be86 100644 --- a/components/Forms/BookingWidget/form.module.css +++ b/components/Forms/BookingWidget/form.module.css @@ -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; } } diff --git a/components/Forms/Edit/Profile/index.tsx b/components/Forms/Edit/Profile/index.tsx index 33f93e0fd..89097ae31 100644 --- a/components/Forms/Edit/Profile/index.tsx +++ b/components/Forms/Edit/Profile/index.tsx @@ -69,6 +69,7 @@ export default function Form({ user }: EditFormProps) { retypeNewPassword: "", }, mode: "all", + criteriaMode: "all", resolver: zodResolver(editProfileSchema), reValidateMode: "onChange", }) diff --git a/components/Forms/Edit/Profile/schema.ts b/components/Forms/Edit/Profile/schema.ts index 7330214ff..bf4e374cc 100644 --- a/components/Forms/Edit/Profile/schema.ts +++ b/components/Forms/Edit/Profile/schema.ts @@ -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", diff --git a/components/Forms/Register/form.module.css b/components/Forms/Register/form.module.css new file mode 100644 index 000000000..612cbc8f8 --- /dev/null +++ b/components/Forms/Register/form.module.css @@ -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; + } +} diff --git a/components/Forms/Register/index.tsx b/components/Forms/Register/index.tsx new file mode 100644 index 000000000..e154817ff --- /dev/null +++ b/components/Forms/Register/index.tsx @@ -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({ + 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 ( +
+ {title} + + +
+
+
+ + {intl.formatMessage({ id: "Contact information" })} + +
+
+ + +
+
+
+
+ + {intl.formatMessage({ id: "Birth date" })} + +
+ +
+
+ + +
+ + +
+
+
+ + {intl.formatMessage({ id: "Password" })} + +
+ +
+
+
+ + {intl.formatMessage({ id: "Terms and conditions" })} + +
+ + + {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", + })}{" "} + + {intl.formatMessage({ id: "Scandic's Privacy Policy." })} + + + +
+ + +
+
+ ) +} diff --git a/components/Forms/Register/schema.ts b/components/Forms/Register/schema.ts new file mode 100644 index 000000000..982641d2c --- /dev/null +++ b/components/Forms/Register/schema.ts @@ -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 diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx new file mode 100644 index 000000000..8efc418a1 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -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 ( +
+
+ + {mappedRooms.map( + (room, idx) => + `${room.total} x ${room.type}${mappedRooms.length > 1 && idx + 1 !== mappedRooms.length ? ", " : ""}` + )} + + + {fromDate} + + {toDate} + + + {intl.formatMessage({ id: "See room details" })} + + +
+ +
+
+ + {`${nights}, ${adults}`} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "4536", currency: "SEK" } + )} + +
+ {addOns.map((addOn) => ( +
+ {addOn.title} + {addOn.price} +
+ ))} +
+ +
+
+
+ + {intl.formatMessage({ id: "Total incl VAT" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "4686", currency: "SEK" } + )} + +
+
+ + {intl.formatMessage({ id: "Approx." })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "455", currency: "EUR" } + )} + +
+
+
+
+ + {intl.formatMessage({ id: "Member price" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "4219", currency: "SEK" } + )} + +
+
+ + {intl.formatMessage({ id: "Approx." })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "412", currency: "EUR" } + )} + +
+
+
+
+ ) +} diff --git a/components/HotelReservation/EnterDetails/Summary/summary.module.css b/components/HotelReservation/EnterDetails/Summary/summary.module.css new file mode 100644 index 000000000..16ca412c5 --- /dev/null +++ b/components/HotelReservation/EnterDetails/Summary/summary.module.css @@ -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); +} diff --git a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css b/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css index 268bfe4fe..9eefdfb33 100644 --- a/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css +++ b/components/HotelReservation/HotelSelectionHeader/hotelSelectionHeader.module.css @@ -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 { diff --git a/components/HotelReservation/SelectRate/Summary/index.tsx b/components/HotelReservation/SelectRate/Summary/index.tsx deleted file mode 100644 index 1cff67248..000000000 --- a/components/HotelReservation/SelectRate/Summary/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -"use client" -import styles from "./summary.module.css" - -export default function Summary() { - return
Summary TBI
-} diff --git a/components/HotelReservation/SelectRate/Summary/summary.module.css b/components/HotelReservation/SelectRate/Summary/summary.module.css deleted file mode 100644 index ec81ef8e9..000000000 --- a/components/HotelReservation/SelectRate/Summary/summary.module.css +++ /dev/null @@ -1,2 +0,0 @@ -.wrapper { -} diff --git a/components/Icons/EyeHide.tsx b/components/Icons/EyeHide.tsx new file mode 100644 index 000000000..cc1db5926 --- /dev/null +++ b/components/Icons/EyeHide.tsx @@ -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 ( + + + + + + + + + ) +} diff --git a/components/Icons/EyeShow.tsx b/components/Icons/EyeShow.tsx new file mode 100644 index 000000000..c3fdaf17d --- /dev/null +++ b/components/Icons/EyeShow.tsx @@ -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 ( + + + + + + + + + ) +} diff --git a/components/Icons/get-icon-by-icon-name.ts b/components/Icons/get-icon-by-icon-name.ts index 3a443cea2..9bfc4d9c7 100644 --- a/components/Icons/get-icon-by-icon-name.ts +++ b/components/Icons/get-icon-by-icon-name.ts @@ -29,6 +29,8 @@ import { DoorOpenIcon, ElectricBikeIcon, EmailIcon, + EyeHideIcon, + EyeShowIcon, FitnessIcon, GiftIcon, GlobeIcon, @@ -114,6 +116,10 @@ export function getIconByIconName(icon?: IconName): FC | 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: diff --git a/components/Icons/icon.module.css b/components/Icons/icon.module.css index bb86becdb..ec5a15fd4 100644 --- a/components/Icons/icon.module.css +++ b/components/Icons/icon.module.css @@ -71,3 +71,8 @@ .baseButtonTertiaryOnFillNormal * { fill: var(--Base-Button-Tertiary-On-Fill-Normal); } + +.baseButtonTextOnFillNormal, +.baseButtonTextOnFillNormal * { + fill: var(--Base-Button-Text-On-Fill-Normal); +} diff --git a/components/Icons/index.tsx b/components/Icons/index.tsx index f26933255..31b77b1e5 100644 --- a/components/Icons/index.tsx +++ b/components/Icons/index.tsx @@ -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" diff --git a/components/Icons/variants.ts b/components/Icons/variants.ts index 911d183da..d319c466e 100644 --- a/components/Icons/variants.ts +++ b/components/Icons/variants.ts @@ -6,6 +6,7 @@ const config = { variants: { color: { baseButtonTertiaryOnFillNormal: styles.baseButtonTertiaryOnFillNormal, + baseButtonTextOnFillNormal: styles.baseButtonTextOnFillNormal, baseIconLowContrast: styles.baseIconLowContrast, black: styles.black, blue: styles.blue, diff --git a/components/TempDesignSystem/Form/Checkbox/checkbox.module.css b/components/TempDesignSystem/Form/Checkbox/checkbox.module.css new file mode 100644 index 000000000..7f7c8f04f --- /dev/null +++ b/components/TempDesignSystem/Form/Checkbox/checkbox.module.css @@ -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; +} diff --git a/components/TempDesignSystem/Form/Checkbox/checkbox.ts b/components/TempDesignSystem/Form/Checkbox/checkbox.ts new file mode 100644 index 000000000..8588b7401 --- /dev/null +++ b/components/TempDesignSystem/Form/Checkbox/checkbox.ts @@ -0,0 +1,7 @@ +import { RegisterOptions } from "react-hook-form" + +export interface CheckboxProps + extends React.InputHTMLAttributes { + name: string + registerOptions?: RegisterOptions +} diff --git a/components/TempDesignSystem/Form/Checkbox/index.tsx b/components/TempDesignSystem/Form/Checkbox/index.tsx new file mode 100644 index 000000000..cb4a68cde --- /dev/null +++ b/components/TempDesignSystem/Form/Checkbox/index.tsx @@ -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) { + const { control } = useFormContext() + const { field, fieldState } = useController({ + control, + name, + rules: registerOptions, + }) + + return ( + + {({ isSelected }) => ( + <> +
+
+ {isSelected && } +
+ {children} +
+ {fieldState.error ? ( + + + {fieldState.error.message} + + ) : null} + + )} +
+ ) +} diff --git a/components/TempDesignSystem/Form/Country/index.tsx b/components/TempDesignSystem/Form/Country/index.tsx index 9777a4a48..0a5406640 100644 --- a/components/TempDesignSystem/Form/Country/index.tsx +++ b/components/TempDesignSystem/Form/Country/index.tsx @@ -68,6 +68,7 @@ export default function CountrySelect({ onSelectionChange={handleChange} ref={field.ref} selectedKey={field.value} + data-testid={name} >