feat(WEB-163): edit profile field validation

This commit is contained in:
Simon Emanuelsson
2024-07-01 15:37:12 +02:00
parent 2a71a45d3d
commit 56bfbc3b71
30 changed files with 588 additions and 134 deletions

View File

@@ -1,25 +1,41 @@
"use server" "use server"
// import { editProfileSchema } from "@/components/Forms/Edit/Profile/schema"
import { ZodError } from "zod" import { ZodError } from "zod"
import { editProfileSchema } from "@/components/Forms/Edit/Profile/schema"
import { type State, Status } from "@/types/components/myPages/myProfile/edit" import { type State, Status } from "@/types/components/myPages/myProfile/edit"
export async function editProfile(_prevState: State, values: FormData) { export async function editProfile(_prevState: State, values: FormData) {
try { try {
const data = Object.fromEntries(values.entries()) const data: Record<string, any> = Object.fromEntries(values.entries())
/** /**
* TODO: Update profile data when endpoint from * TODO: Update profile data when endpoint from
* API team is ready * API team is ready
*/ */
console.info(`EditProfileSchema.Parse Data`) console.info(`Raw Data`)
console.log(data) console.log(data)
data.address = {
return { city: data["address.city"],
message: "All good!", countryCode: data["address.countryCode"],
status: Status.success, streetAddress: data["address.streetAddress"],
zipCode: data["address.zipCode"],
}
const parsedData = editProfileSchema.safeParse(data)
if (parsedData.success) {
console.info(`Success`)
console.log(parsedData.data)
return {
message: "All good!",
status: Status.success,
}
} else {
console.error(parsedData.error)
return {
message: "Invalid data, parse failed!",
status: Status.error,
}
} }
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {

View File

@@ -7,6 +7,7 @@ import { languageSelect } from "@/constants/languages"
import CountrySelect from "@/components/TempDesignSystem/Form/Country" import CountrySelect from "@/components/TempDesignSystem/Form/Country"
import DateSelect from "@/components/TempDesignSystem/Form/Date" import DateSelect from "@/components/TempDesignSystem/Form/Date"
import Input from "@/components/TempDesignSystem/Form/Input" import Input from "@/components/TempDesignSystem/Form/Input"
import NewPassword from "@/components/TempDesignSystem/Form/NewPassword"
import Phone from "@/components/TempDesignSystem/Form/Phone" import Phone from "@/components/TempDesignSystem/Form/Phone"
import Select from "@/components/TempDesignSystem/Form/Select" import Select from "@/components/TempDesignSystem/Form/Select"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
@@ -21,8 +22,8 @@ export default function FormContent() {
const country = formatMessage({ id: "Country" }) const country = formatMessage({ id: "Country" })
const email = `${formatMessage({ id: "Email" })} ${formatMessage({ id: "Address" }).toLowerCase()}` const email = `${formatMessage({ id: "Email" })} ${formatMessage({ id: "Address" }).toLowerCase()}`
const street = formatMessage({ id: "Address" }) const street = formatMessage({ id: "Address" })
const phoneNumber = formatMessage({ id: "Phone number" })
const password = formatMessage({ id: "Current password" }) const password = formatMessage({ id: "Current password" })
const newPassword = formatMessage({ id: "New password" })
const retypeNewPassword = formatMessage({ id: "Retype new password" }) const retypeNewPassword = formatMessage({ id: "Retype new password" })
const zipCode = formatMessage({ id: "Zip code" }) const zipCode = formatMessage({ id: "Zip code" })
@@ -46,7 +47,7 @@ export default function FormContent() {
label={zipCode} label={zipCode}
name="address.zipCode" name="address.zipCode"
placeholder={zipCode} placeholder={zipCode}
required registerOptions={{ required: true }}
/> />
<CountrySelect <CountrySelect
label={country} label={country}
@@ -55,12 +56,17 @@ export default function FormContent() {
registerOptions={{ required: true }} registerOptions={{ required: true }}
/> />
</div> </div>
<Input label={email} name="email" placeholder={email} required /> <Input
<Phone label={email}
label={formatMessage({ id: "Phone number" })} name="email"
name="phoneNumber" placeholder={email}
placeholder={formatMessage({ id: "Phone number" })}
registerOptions={{ required: true }} registerOptions={{ required: true }}
type="email"
/>
<Phone
label={phoneNumber}
name="phoneNumber"
placeholder={phoneNumber}
/> />
<Select <Select
items={languageSelect} items={languageSelect}
@@ -79,12 +85,7 @@ export default function FormContent() {
placeholder={password} placeholder={password}
type="password" type="password"
/> />
<Input <NewPassword />
label={newPassword}
name="newPassword"
placeholder={newPassword}
type="password"
/>
<Input <Input
label={retypeNewPassword} label={retypeNewPassword}
name="retypeNewPassword" name="retypeNewPassword"

View File

@@ -1,13 +1,16 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { useParams } from "next/navigation"
import { useFormState as useReactFormState } from "react-dom" import { useFormState as useReactFormState } from "react-dom"
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { profile } from "@/constants/routes/myPages"
import { editProfile } from "@/actions/editProfile" import { editProfile } from "@/actions/editProfile"
import Header from "@/components/Profile/Header" import Header from "@/components/Profile/Header"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider" import Link from "@/components/TempDesignSystem/Link"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import FormContent from "./FormContent" import FormContent from "./FormContent"
@@ -19,11 +22,14 @@ import type {
EditFormProps, EditFormProps,
State, State,
} from "@/types/components/myPages/myProfile/edit" } from "@/types/components/myPages/myProfile/edit"
import type { Lang } from "@/constants/languages"
const formId = "edit-profile" const formId = "edit-profile"
export default function Form({ user }: EditFormProps) { export default function Form({ user }: EditFormProps) {
const { formatMessage } = useIntl() const { formatMessage } = useIntl()
const params = useParams()
const lang = params.lang as Lang
/** /**
* like react, react-hook-form also exports a useFormState hook, * like react, react-hook-form also exports a useFormState hook,
* we want to clearly keep them separate by naming. * we want to clearly keep them separate by naming.
@@ -33,12 +39,14 @@ export default function Form({ user }: EditFormProps) {
null null
) )
const form = useForm<EditProfileSchema>({ const methods = useForm<EditProfileSchema>({
defaultValues: { defaultValues: {
"address.city": user.address.city ?? "", address: {
"address.countryCode": user.address.countryCode ?? "", city: user.address.city ?? "",
"address.streetAddress": user.address.streetAddress ?? "", countryCode: user.address.countryCode ?? "",
"address.zipCode": user.address.zipCode ?? "", streetAddress: user.address.streetAddress ?? "",
zipCode: user.address.zipCode ?? "",
},
dateOfBirth: user.dateOfBirth, dateOfBirth: user.dateOfBirth,
email: user.email, email: user.email,
language: user.language, language: user.language,
@@ -53,7 +61,7 @@ export default function Form({ user }: EditFormProps) {
}) })
return ( return (
<FormProvider {...form}> <>
<Header> <Header>
<hgroup> <hgroup>
<Title as="h4" color="red" level="h1"> <Title as="h4" color="red" level="h1">
@@ -64,17 +72,15 @@ export default function Form({ user }: EditFormProps) {
</Title> </Title>
</hgroup> </hgroup>
<div className={styles.btns}> <div className={styles.btns}>
<Button <Button asChild intent="secondary" size="small" theme="base">
form={formId} <Link href={profile[lang]}>
intent="secondary" {formatMessage({ id: "Discard changes" })}
size="small" </Link>
theme="base"
type="reset"
>
{formatMessage({ id: "Discard changes" })}
</Button> </Button>
<Button <Button
disabled={!form.formState.isValid || form.formState.isSubmitting} disabled={
!methods.formState.isValid || methods.formState.isSubmitting
}
form={formId} form={formId}
intent="primary" intent="primary"
size="small" size="small"
@@ -85,10 +91,11 @@ export default function Form({ user }: EditFormProps) {
</Button> </Button>
</div> </div>
</Header> </Header>
<Divider color="burgundy" opacity={8} />
<form action={formAction} className={styles.form} id={formId}> <form action={formAction} className={styles.form} id={formId}>
<FormContent /> <FormProvider {...methods}>
<FormContent />
</FormProvider>
</form> </form>
</FormProvider> </>
) )
} }

View File

@@ -1,24 +1,98 @@
import { z } from "zod" import { z } from "zod"
// import { phoneValidator } from "@/utils/phoneValidator" import { Key } from "@/components/TempDesignSystem/Form/NewPassword/newPassword"
import { phoneValidator } from "@/utils/phoneValidator"
export const editProfileSchema = z.object({ const countryRequiredMsg = "Country is required"
"address.city": z.string().optional(), export const editProfileSchema = z
"address.countryCode": z.string().min(1), .object({
"address.streetAddress": z.string().optional(), address: z.object({
"address.zipCode": z.string().min(1), city: z.string().optional(),
dateOfBirth: z.string().min(1), countryCode: z
email: z.string().email(), .string({
language: z.string(), required_error: countryRequiredMsg,
phoneNumber: z.string(), invalid_type_error: countryRequiredMsg,
// phoneValidator( })
// "Phone is required", .min(1, countryRequiredMsg),
// "Please enter a valid phone number" streetAddress: z.string().optional(),
// ), zipCode: z.string().min(1, "Zip code is required"),
}),
dateOfBirth: z.string().min(1),
email: z.string().email(),
language: z.string(),
phoneNumber: phoneValidator(
"Phone is required",
"Please enter a valid phone number"
),
currentPassword: z.string().optional(), currentPassword: z.string().optional(),
newPassword: z.string().optional(), newPassword: z.string().optional(),
retypeNewPassword: z.string().optional(), retypeNewPassword: z.string().optional(),
}) })
.superRefine((data, ctx) => {
if (data.currentPassword) {
if (!data.newPassword) {
ctx.addIssue({
code: "custom",
message: "New password is required",
path: ["newPassword"],
})
}
if (!data.retypeNewPassword) {
ctx.addIssue({
code: "custom",
message: "Retype new password is required",
path: ["retypeNewPassword"],
})
}
} else {
if (data.newPassword || data.retypeNewPassword) {
ctx.addIssue({
code: "custom",
message: "Current password is required",
path: ["currentPassword"],
})
}
}
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",
message: "Retype new password is required",
path: ["retypeNewPassword"],
})
}
if (data.retypeNewPassword !== data.newPassword) {
ctx.addIssue({
code: "custom",
message: "Retype new password does not match new password",
path: ["retypeNewPassword"],
})
}
})
export type EditProfileSchema = z.infer<typeof editProfileSchema> export type EditProfileSchema = z.infer<typeof editProfileSchema>

View File

@@ -0,0 +1,36 @@
import { iconVariants } from "./variants"
import type { IconProps } from "@/types/components/icon"
export default function CloseIcon({ className, color, ...props }: IconProps) {
const classNames = iconVariants({ className, color })
return (
<svg
className={classNames}
fill="none"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<mask
height="20"
id="mask0_7531_17458"
maskUnits="userSpaceOnUse"
style={{ maskType: "alpha" }}
width="20"
x="0"
y="0"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_7531_17458)">
<path
d="M10 11.0936L7.58333 13.5207C7.43056 13.6734 7.24653 13.7498 7.03125 13.7498C6.81597 13.7498 6.63194 13.6734 6.47917 13.5207C6.32639 13.3679 6.25 13.1839 6.25 12.9686C6.25 12.7533 6.32639 12.5693 6.47917 12.4165L8.90625 9.99984L6.47917 7.59359C6.32639 7.44081 6.25 7.25678 6.25 7.0415C6.25 6.82623 6.32639 6.6422 6.47917 6.48942C6.63194 6.33664 6.81597 6.26025 7.03125 6.26025C7.24653 6.26025 7.43056 6.33664 7.58333 6.48942L10 8.9165L12.4062 6.48942C12.559 6.33664 12.7431 6.26025 12.9583 6.26025C13.1736 6.26025 13.3576 6.33664 13.5104 6.48942C13.6701 6.64914 13.75 6.83491 13.75 7.04671C13.75 7.25852 13.6701 7.44081 13.5104 7.59359L11.0833 9.99984L13.5104 12.4165C13.6632 12.5693 13.7396 12.7533 13.7396 12.9686C13.7396 13.1839 13.6632 13.3679 13.5104 13.5207C13.3507 13.6804 13.1649 13.7603 12.9531 13.7603C12.7413 13.7603 12.559 13.6804 12.4062 13.5207L10 11.0936Z"
fill="#CD0921"
/>
</g>
</svg>
)
}

View File

@@ -6,6 +6,7 @@ export { default as CheckIcon } from "./Check"
export { default as CheckCircleIcon } from "./CheckCircle" export { default as CheckCircleIcon } from "./CheckCircle"
export { default as ChevronDownIcon } from "./ChevronDown" export { default as ChevronDownIcon } from "./ChevronDown"
export { default as ChevronRightIcon } from "./ChevronRight" export { default as ChevronRightIcon } from "./ChevronRight"
export { default as CloseIcon } from "./Close"
export { default as EmailIcon } from "./Email" export { default as EmailIcon } from "./Email"
export { default as GlobeIcon } from "./Globe" export { default as GlobeIcon } from "./Globe"
export { default as HouseIcon } from "./House" export { default as HouseIcon } from "./House"

View File

@@ -19,6 +19,13 @@
padding: var(--Spacing-x1) var(--Spacing-x2); padding: var(--Spacing-x1) var(--Spacing-x2);
} }
.comboBoxContainer:has(
.input[data-invalid="true"],
.input[aria-invalid="true"]
) {
border-color: var(--Scandic-Red-60);
}
.label { .label {
grid-area: label; grid-area: label;
} }
@@ -66,6 +73,8 @@
var(--Spacing-x2); var(--Spacing-x2);
} }
.listBoxItem[data-focused="true"],
.listBoxItem[data-focus-visible="true"],
.listBoxItem[data-selected="true"], .listBoxItem[data-selected="true"],
.listBoxItem:hover { .listBoxItem:hover {
background-color: var(--Scandic-Blue-00); background-color: var(--Scandic-Blue-00);

View File

@@ -1,10 +1,8 @@
"use client" "use client"
import { ErrorMessage } from "@hookform/error-message"
import { useState } from "react" import { useState } from "react"
import { import {
Button, Button,
ComboBox, ComboBox,
FieldError,
Input, Input,
type Key, type Key,
ListBox, ListBox,
@@ -18,6 +16,7 @@ import Label from "@/components/TempDesignSystem/Form/Label"
import SelectChevron from "@/components/TempDesignSystem/Form/SelectChevron" import SelectChevron from "@/components/TempDesignSystem/Form/SelectChevron"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import ErrorMessage from "../ErrorMessage"
import { countries } from "./countries" import { countries } from "./countries"
import styles from "./country.module.css" import styles from "./country.module.css"
@@ -42,7 +41,7 @@ export default function CountrySelect({
} }
} }
const { control, setValue } = useFormContext() const { control, setValue } = useFormContext()
const { field } = useController({ const { field, fieldState, formState } = useController({
control, control,
name, name,
rules: registerOptions, rules: registerOptions,
@@ -60,6 +59,7 @@ export default function CountrySelect({
aria-label={formatMessage({ id: "Select country of residence" })} aria-label={formatMessage({ id: "Select country of residence" })}
className={styles.select} className={styles.select}
isRequired={!!registerOptions?.required} isRequired={!!registerOptions?.required}
isInvalid={fieldState.invalid}
name={field.name} name={field.name}
onBlur={field.onBlur} onBlur={field.onBlur}
onSelectionChange={handleChange} onSelectionChange={handleChange}
@@ -85,9 +85,7 @@ export default function CountrySelect({
<SelectChevron /> <SelectChevron />
</Button> </Button>
</div> </div>
<FieldError> <ErrorMessage errors={formState.errors} name={name} />
<ErrorMessage name={name} />
</FieldError>
<Popover <Popover
className={styles.popover} className={styles.popover}
placement="bottom" placement="bottom"

View File

@@ -8,5 +8,5 @@ export const enum DateName {
export interface DateProps export interface DateProps
extends React.SelectHTMLAttributes<HTMLSelectElement> { extends React.SelectHTMLAttributes<HTMLSelectElement> {
name: string name: string
registerOptions: RegisterOptions registerOptions?: RegisterOptions
} }

View File

@@ -1,11 +1,6 @@
"use client" "use client"
import { parseDate } from "@internationalized/date" import { parseDate } from "@internationalized/date"
import { import { DateInput, DatePicker, Group } from "react-aria-components"
DateInput,
DatePicker,
DateSegment,
Group,
} from "react-aria-components"
import { useController, useFormContext, useWatch } from "react-hook-form" import { useController, useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -23,7 +18,7 @@ import type { Key } from "react-aria-components"
import type { DateProps } from "./date" import type { DateProps } from "./date"
/** TODO: Get selecting with Enter-key to work */ /** TODO: Get selecting with Enter-key to work */
export default function DateSelect({ name, registerOptions }: DateProps) { export default function DateSelect({ name, registerOptions = {} }: DateProps) {
const { formatMessage } = useIntl() const { formatMessage } = useIntl()
const d = useWatch({ name }) const d = useWatch({ name })
const { control, setValue } = useFormContext() const { control, setValue } = useFormContext()
@@ -84,7 +79,7 @@ export default function DateSelect({ name, registerOptions }: DateProps) {
) )
} }
return ( return (
<DateSegment className={styles.day} segment={segment}> <div className={styles.day}>
<Select <Select
aria-label={dayLabel} aria-label={dayLabel}
items={days} items={days}
@@ -92,13 +87,15 @@ export default function DateSelect({ name, registerOptions }: DateProps) {
name={DateName.date} name={DateName.date}
onSelect={createOnSelect(DateName.date)} onSelect={createOnSelect(DateName.date)}
placeholder="DD" placeholder="DD"
required
tabIndex={3}
value={segment.value} value={segment.value}
/> />
</DateSegment> </div>
) )
case "month": case "month":
return ( return (
<DateSegment className={styles.month} segment={segment}> <div className={styles.month}>
<Select <Select
aria-label={monthLabel} aria-label={monthLabel}
items={months} items={months}
@@ -106,13 +103,15 @@ export default function DateSelect({ name, registerOptions }: DateProps) {
name={DateName.month} name={DateName.month}
onSelect={createOnSelect(DateName.month)} onSelect={createOnSelect(DateName.month)}
placeholder="MM" placeholder="MM"
required
tabIndex={2}
value={segment.value} value={segment.value}
/> />
</DateSegment> </div>
) )
case "year": case "year":
return ( return (
<DateSegment className={styles.year} segment={segment}> <div className={styles.year}>
<Select <Select
aria-label={yearLabel} aria-label={yearLabel}
items={years} items={years}
@@ -120,9 +119,11 @@ export default function DateSelect({ name, registerOptions }: DateProps) {
name={DateName.year} name={DateName.year}
onSelect={createOnSelect(DateName.year)} onSelect={createOnSelect(DateName.year)}
placeholder="YYYY" placeholder="YYYY"
required
tabIndex={1}
value={segment.value} value={segment.value}
/> />
</DateSegment> </div>
) )
default: default:
/** DateInput forces return of ReactElement */ /** DateInput forces return of ReactElement */

View File

@@ -0,0 +1,13 @@
import { InfoCircleIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./error.module.css"
export default function Error({ children }: React.PropsWithChildren) {
return (
<Caption className={styles.message} fontOnly>
<InfoCircleIcon color="red" />
{children}
</Caption>
)
}

View File

@@ -1,9 +1,6 @@
import { ErrorMessage as RHFErrorMessage } from "@hookform/error-message" import { ErrorMessage as RHFErrorMessage } from "@hookform/error-message"
import { InfoCircleIcon } from "@/components/Icons" import Error from "./Error"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./error.module.css"
import type { ErrorMessageProps } from "./errorMessage" import type { ErrorMessageProps } from "./errorMessage"
@@ -15,12 +12,7 @@ export default function ErrorMessage<T>({
<RHFErrorMessage <RHFErrorMessage
errors={errors} errors={errors}
name={name} name={name}
render={({ message }) => ( render={({ message }) => <Error>{message}</Error>}
<Caption className={styles.message} fontOnly>
<InfoCircleIcon color="red" />
{message}
</Caption>
)}
/> />
) )
} }

View File

@@ -2,66 +2,86 @@
import { import {
Input as AriaInput, Input as AriaInput,
Label as AriaLabel, Label as AriaLabel,
Text,
TextField, TextField,
} from "react-aria-components" } from "react-aria-components"
import { useController, useFormContext } from "react-hook-form" import { Controller, useFormContext } from "react-hook-form"
import ErrorMessage from "@/components/TempDesignSystem/Form/ErrorMessage" import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
import Label from "@/components/TempDesignSystem/Form/Label" import Label from "@/components/TempDesignSystem/Form/Label"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./input.module.css" import styles from "./input.module.css"
import type { HTMLAttributes, WheelEvent } from "react"
import type { InputProps } from "./input" import type { InputProps } from "./input"
export default function Input({ export default function Input({
"aria-label": ariaLabel, "aria-label": ariaLabel,
disabled, disabled = false,
helpText = "",
label, label,
name, name,
placeholder = "", placeholder = "",
registerOptions = {}, registerOptions = {},
required = false,
type = "text", type = "text",
}: InputProps) { }: InputProps) {
const { control } = useFormContext() const { control } = useFormContext()
const rules = { let numberAttributes: HTMLAttributes<HTMLInputElement> = {}
...registerOptions, if (type === "number") {
required: numberAttributes.onWheel = function (evt: WheelEvent<HTMLInputElement>) {
"required" in registerOptions ? !!registerOptions.required : required, evt.currentTarget.blur()
}
} }
const { field, fieldState, formState } = useController({
control,
name,
rules,
})
return ( return (
<TextField <Controller
aria-label={ariaLabel} disabled={disabled}
defaultValue={field.value} control={control}
isDisabled={disabled ?? field.disabled} name={name}
isInvalid={fieldState.invalid} rules={registerOptions}
isRequired={!!registerOptions?.required} render={({ field, fieldState }) => (
name={field.name} <TextField
type={type} aria-label={ariaLabel}
> isDisabled={field.disabled}
<AriaLabel className={styles.container} htmlFor={field.name}> isInvalid={fieldState.invalid}
<Body asChild fontOnly> isRequired={!!registerOptions.required}
<AriaInput name={field.name}
className={styles.input} onBlur={field.onBlur}
id={field.name} onChange={field.onChange}
name={field.name} validationBehavior="aria"
onBlur={field.onBlur} value={field.value}
onChange={field.onChange} >
placeholder={placeholder} <AriaLabel className={styles.container} htmlFor={field.name}>
ref={field.ref} <Body asChild fontOnly>
required={rules.required} <AriaInput
/> {...numberAttributes}
</Body> aria-labelledby={field.name}
<Label required={rules.required}>{label}</Label> className={styles.input}
</AriaLabel> id={field.name}
<ErrorMessage errors={formState.errors} name={field.name} /> placeholder={placeholder}
</TextField> type={type}
/>
</Body>
<Label required={!!registerOptions.required}>{label}</Label>
</AriaLabel>
{helpText && !fieldState.error ? (
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<CheckIcon height={20} width={30} />
{helpText}
</Text>
</Caption>
) : null}
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</TextField>
)}
/>
) )
} }

View File

@@ -22,6 +22,16 @@
border-color: var(--Scandic-Blue-90); 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 { .input {
background: none; background: none;
border: none; border: none;
@@ -42,3 +52,21 @@
height: 18px; height: 18px;
outline: none; 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;
}

View File

@@ -1,7 +1,8 @@
import type { RegisterOptions } from "react-hook-form" import type { RegisterOptions, UseFormRegister } from "react-hook-form"
export interface InputProps export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> { extends React.InputHTMLAttributes<HTMLInputElement> {
helpText?: string
label: string label: string
name: string name: string
registerOptions?: RegisterOptions registerOptions?: RegisterOptions

View File

@@ -28,3 +28,7 @@ input:placeholder-shown ~ .label {
align-self: center; align-self: center;
grid-row: 1/-1; grid-row: 1/-1;
} }
input:disabled ~ .label {
color: var(--Main-Grey-40);
}

View File

@@ -0,0 +1,113 @@
"use client"
import {
Input as AriaInput,
Label as AriaLabel,
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 Label from "@/components/TempDesignSystem/Form/Label"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { type IconProps, Key, type NewPasswordProps } from "./newPassword"
import styles from "./newPassword.module.css"
export default function NewPassword({
"aria-label": ariaLabel,
disabled = false,
placeholder = "",
registerOptions = {},
}: NewPasswordProps) {
const { control } = useFormContext()
const { formatMessage } = useIntl()
return (
<Controller
disabled={disabled}
control={control}
name="newPassword"
rules={registerOptions}
render={({ field, fieldState }) => {
const messages = (fieldState.error?.message?.split(",") ?? []) as Key[]
return (
<TextField
aria-label={ariaLabel}
isDisabled={field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
type="password"
>
<AriaLabel className={styles.container} htmlFor={field.name}>
<Body asChild fontOnly>
<AriaInput
aria-labelledby={field.name}
className={styles.input}
id={field.name}
placeholder={placeholder}
type="password"
/>
</Body>
<Label required={!!registerOptions.required}>
{formatMessage({ id: "New password" })}
</Label>
</AriaLabel>
{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>
</div>
) : null}
{!field.value && fieldState.error ? (
<Error>
<Text className={styles.helpText} slot="description">
{fieldState.error.message}
</Text>
</Error>
) : null}
</TextField>
)
}}
/>
)
}
function Icon({ matcher, messages }: IconProps) {
return messages.includes(matcher) ? (
<CloseIcon color="red" height={20} width={20} />
) : (
<CheckIcon color="green" height={20} width={20} />
)
}

View File

@@ -0,0 +1,71 @@
.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;
gap: var(--Spacing-x-half);
grid-template-rows: auto auto;
height: 60px;
padding: var(--Spacing-x1) var(--Spacing-x2);
transition: border-color 200ms ease;
}
.container:has(.input:not(:focus):placeholder-shown) {
gap: 0;
grid-template-rows: 1fr;
}
.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;
}
.input:focus,
.input:focus:placeholder-shown {
height: 18px;
outline: none;
}
.input:disabled {
color: var(--Main-Grey-40);
}
.errors {
display: flex;
flex-wrap: wrap;
gap: var(--Spacing-x-one-and-half) var(--Spacing-x1);
padding-top: var(--Spacing-x1);
}
.helpText {
align-items: flex-start;
display: flex;
gap: var(--Spacing-x-half);
}

View File

@@ -0,0 +1,19 @@
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
registerOptions?: RegisterOptions
}
export interface IconProps {
matcher: Key
messages: Key[]
}

View File

@@ -132,7 +132,6 @@ export default function Phone({
</AriaLabel> </AriaLabel>
<ErrorMessage errors={formState.errors} name={field.name} /> <ErrorMessage errors={formState.errors} name={field.name} />
</TextField> </TextField>
<ErrorMessage errors={formState.errors} name={name} />
</div> </div>
) )
} }

View File

@@ -108,6 +108,10 @@
border-color: var(--Scandic-Blue-90); border-color: var(--Scandic-Blue-90);
} }
.inputContainer:has(.input[data-invalid="true"], .input[aria-invalid="true"]) {
border-color: var(--Scandic-Red-60);
}
.input { .input {
background: none; background: none;
border: none; border: none;

View File

@@ -31,6 +31,8 @@ export default function Select({
name, name,
onSelect, onSelect,
placeholder, placeholder,
required = false,
tabIndex,
value, value,
}: SelectProps) { }: SelectProps) {
const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined) const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined)
@@ -58,8 +60,10 @@ export default function Select({
> >
<Body asChild fontOnly> <Body asChild fontOnly>
<Button className={styles.input}> <Button className={styles.input}>
<div className={styles.inputContentWrapper}> <div className={styles.inputContentWrapper} tabIndex={tabIndex}>
<Label size="small">{label}</Label> <Label required={required} size="small">
{label}
</Label>
<SelectValue /> <SelectValue />
</div> </div>
<SelectChevron /> <SelectChevron />

View File

@@ -58,7 +58,8 @@
padding: var(--Spacing-x2); padding: var(--Spacing-x2);
} }
.listBoxItem[data-focused="true"] { .listBoxItem[data-focused="true"],
.listBoxItem[data-selected="true"] {
background: var(--UI-Input-Controls-Surface-Hover, var(--Scandic-Blue-00)); background: var(--UI-Input-Controls-Surface-Hover, var(--Scandic-Blue-00));
outline: none; outline: none;
} }

View File

@@ -1,4 +1,4 @@
import type { Key } from "react-aria-components" import type { Key, SelectProps as AriaSelectProps } from "react-aria-components"
export interface SelectProps export interface SelectProps
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onSelect"> { extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onSelect"> {

View File

@@ -8,6 +8,7 @@
"Book": "Bestil", "Book": "Bestil",
"Booking number": "Bestillingsnummer", "Booking number": "Bestillingsnummer",
"Cancel": "Afbestille", "Cancel": "Afbestille",
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Tjek de kreditkort, der er gemt på din profil. Betal med et gemt kort, når du er logget ind for en mere jævn weboplevelse.",
"City": "By", "City": "By",
"City/State": "By/Stat", "City/State": "By/Stat",
"Click here to log in": "Klik her for at logge ind", "Click here to log in": "Klik her for at logge ind",
@@ -21,6 +22,7 @@
"Country code": "Landekode", "Country code": "Landekode",
"Current level": "Nuværende niveau", "Current level": "Nuværende niveau",
"Current password": "Nuværende kodeord", "Current password": "Nuværende kodeord",
"characters": "tegn",
"Date of Birth": "Fødselsdato", "Date of Birth": "Fødselsdato",
"Day": "Dag", "Day": "Dag",
"Description": "Beskrivelse", "Description": "Beskrivelse",
@@ -45,6 +47,7 @@
"Month": "Måned", "Month": "Måned",
"My communication preferences": "Mine kommunikationspræferencer", "My communication preferences": "Mine kommunikationspræferencer",
"My credit cards": "Mine kreditkort", "My credit cards": "Mine kreditkort",
"My membership cards": "Mine medlemskort",
"My pages": "Mine sider", "My pages": "Mine sider",
"My wishes": "Mine ønsker", "My wishes": "Mine ønsker",
"New password": "Nyt kodeord", "New password": "Nyt kodeord",
@@ -55,6 +58,7 @@
"Not found": "Ikke fundet", "Not found": "Ikke fundet",
"night": "nat", "night": "nat",
"nights": "nætter", "nights": "nætter",
"number": "nummer",
"On your journey": "På din rejse", "On your journey": "På din rejse",
"Open": "Åben", "Open": "Åben",
"or": "eller", "or": "eller",
@@ -79,10 +83,13 @@
"Skip to main content": "Spring over og gå til hovedindhold", "Skip to main content": "Spring over og gå til hovedindhold",
"Something went wrong!": "Noget gik galt!", "Something went wrong!": "Noget gik galt!",
"Street": "Gade", "Street": "Gade",
"special character": "speciel karakter",
"Total Points": "Samlet antal point", "Total Points": "Samlet antal point",
"Transaction date": "Overførselsdato", "Transaction date": "Overførselsdato",
"Transactions": "Transaktioner", "Transactions": "Transaktioner",
"to": "til",
"User information": "Brugeroplysninger", "User information": "Brugeroplysninger",
"uppercase letter": "stort bogstav",
"Visiting address": "Besøgsadresse", "Visiting address": "Besøgsadresse",
"Where should you go next?": "Hvor skal du tage hen næste gang?", "Where should you go next?": "Hvor skal du tage hen næste gang?",
"Year": "År", "Year": "År",

View File

@@ -8,6 +8,7 @@
"Book": "Buch", "Book": "Buch",
"Booking number": "Buchungsnummer", "Booking number": "Buchungsnummer",
"Cancel": "Stornieren", "Cancel": "Stornieren",
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Sehen Sie sich die in Ihrem Profil gespeicherten Kreditkarten an. Bezahlen Sie mit einer gespeicherten Karte, wenn Sie angemeldet sind, für ein reibungsloseres Web-Erlebnis.",
"City": "Stadt", "City": "Stadt",
"City/State": "Stadt/Zustand", "City/State": "Stadt/Zustand",
"Click here to log in": "Klicken Sie hier, um sich einzuloggen", "Click here to log in": "Klicken Sie hier, um sich einzuloggen",
@@ -21,6 +22,7 @@
"Country code": "Landesvorwahl", "Country code": "Landesvorwahl",
"Current level": "Aktuelles Level", "Current level": "Aktuelles Level",
"Current password": "Aktuelles Passwort", "Current password": "Aktuelles Passwort",
"characters": "figuren",
"Date of Birth": "Geburtsdatum", "Date of Birth": "Geburtsdatum",
"Day": "Tag", "Day": "Tag",
"Description": "Beschreibung", "Description": "Beschreibung",
@@ -45,6 +47,7 @@
"Month": "Monat", "Month": "Monat",
"My communication preferences": "Meine Kommunikationseinstellungen", "My communication preferences": "Meine Kommunikationseinstellungen",
"My credit cards": "Meine Kreditkarten", "My credit cards": "Meine Kreditkarten",
"My membership cards": "Meine Mitgliedskarten",
"My pages": "Meine Seiten", "My pages": "Meine Seiten",
"My wishes": "Meine Wünsche", "My wishes": "Meine Wünsche",
"New password": "Neues Kennwort", "New password": "Neues Kennwort",
@@ -55,6 +58,7 @@
"Not found": "Nicht gefunden", "Not found": "Nicht gefunden",
"night": "nacht", "night": "nacht",
"nights": "nächte", "nights": "nächte",
"number": "nummer",
"On your journey": "Auf deiner Reise", "On your journey": "Auf deiner Reise",
"Open": "Offen", "Open": "Offen",
"or": "oder", "or": "oder",
@@ -79,10 +83,13 @@
"Skip to main content": "Direkt zum Inhalt", "Skip to main content": "Direkt zum Inhalt",
"Something went wrong!": "Etwas ist schief gelaufen!", "Something went wrong!": "Etwas ist schief gelaufen!",
"Street": "Straße", "Street": "Straße",
"special character": "sonderzeichen",
"Total Points": "Gesamtpunktzahl", "Total Points": "Gesamtpunktzahl",
"Transaction date": "Transaktionsdatum", "Transaction date": "Transaktionsdatum",
"Transactions": "Transaktionen", "Transactions": "Transaktionen",
"to": "zu",
"User information": "Nutzerinformation", "User information": "Nutzerinformation",
"uppercase letter": "großbuchstabe",
"Visiting address": "Besuchsadresse", "Visiting address": "Besuchsadresse",
"Where should you go next?": "Wohin soll es als nächstes gehen?", "Where should you go next?": "Wohin soll es als nächstes gehen?",
"Year": "Jahr", "Year": "Jahr",

View File

@@ -8,6 +8,7 @@
"Book": "Book", "Book": "Book",
"Booking number": "Booking number", "Booking number": "Booking number",
"Cancel": "Cancel", "Cancel": "Cancel",
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.",
"City": "City", "City": "City",
"City/State": "City/State", "City/State": "City/State",
"Click here to log in": "Click here to log in", "Click here to log in": "Click here to log in",
@@ -21,6 +22,7 @@
"Country code": "Country code", "Country code": "Country code",
"Current level": "Current level", "Current level": "Current level",
"Current password": "Current password", "Current password": "Current password",
"characters": "characters",
"Date of Birth": "Date of Birth", "Date of Birth": "Date of Birth",
"Day": "Day", "Day": "Day",
"Description": "Description", "Description": "Description",
@@ -45,6 +47,7 @@
"Month": "Month", "Month": "Month",
"My communication preferences": "My communication preferences", "My communication preferences": "My communication preferences",
"My credit cards": "My credit cards", "My credit cards": "My credit cards",
"My membership cards": "My membership cards",
"My pages": "My pages", "My pages": "My pages",
"My wishes": "My wishes", "My wishes": "My wishes",
"New password": "New password", "New password": "New password",
@@ -55,6 +58,7 @@
"Not found": "Not found", "Not found": "Not found",
"night": "night", "night": "night",
"nights": "nights", "nights": "nights",
"number": "number",
"On your journey": "On your journey", "On your journey": "On your journey",
"Open": "Open", "Open": "Open",
"or": "or", "or": "or",
@@ -79,10 +83,13 @@
"Skip to main content": "Skip to main content", "Skip to main content": "Skip to main content",
"Something went wrong!": "Something went wrong!", "Something went wrong!": "Something went wrong!",
"Street": "Street", "Street": "Street",
"special character": "special character",
"Total Points": "Total Points", "Total Points": "Total Points",
"Transaction date": "Transaction date", "Transaction date": "Transaction date",
"Transactions": "Transactions", "Transactions": "Transactions",
"to": "to",
"User information": "User information", "User information": "User information",
"uppercase letter": "uppercase letter",
"Visiting address": "Visiting address", "Visiting address": "Visiting address",
"Where should you go next?": "Where should you go next?", "Where should you go next?": "Where should you go next?",
"Year": "Year", "Year": "Year",

View File

@@ -8,6 +8,7 @@
"Book": "Kirja", "Book": "Kirja",
"Booking number": "Varausnumero", "Booking number": "Varausnumero",
"Cancel": "Peruuttaa", "Cancel": "Peruuttaa",
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Tarkista profiiliisi tallennetut luottokortit. Maksa tallennetulla kortilla kirjautuneena, jotta verkkokokemus on sujuvampi.",
"City": "Kaupunki", "City": "Kaupunki",
"City/State": "Kaupunki/Osavaltio", "City/State": "Kaupunki/Osavaltio",
"Click here to log in": "Napsauta tästä kirjautuaksesi sisään", "Click here to log in": "Napsauta tästä kirjautuaksesi sisään",
@@ -21,6 +22,7 @@
"Country code": "Maatunnus", "Country code": "Maatunnus",
"Current level": "Nykyinen taso", "Current level": "Nykyinen taso",
"Current password": "Nykyinen salasana", "Current password": "Nykyinen salasana",
"characters": "hahmoja",
"Date of Birth": "Syntymäaika", "Date of Birth": "Syntymäaika",
"Day": "Päivä", "Day": "Päivä",
"Description": "Kuvaus", "Description": "Kuvaus",
@@ -45,6 +47,7 @@
"Month": "Kuukausi", "Month": "Kuukausi",
"My communication preferences": "Viestintämieltymykseni", "My communication preferences": "Viestintämieltymykseni",
"My credit cards": "Minun luottokorttini", "My credit cards": "Minun luottokorttini",
"My membership cards": "Jäsenkorttini",
"My pages": "Omat sivut", "My pages": "Omat sivut",
"My wishes": "Toiveeni", "My wishes": "Toiveeni",
"New password": "Uusi salasana", "New password": "Uusi salasana",
@@ -55,6 +58,7 @@
"Not found": "Ei löydetty", "Not found": "Ei löydetty",
"night": "yö", "night": "yö",
"nights": "yöt", "nights": "yöt",
"number": "määrä",
"On your journey": "Matkallasi", "On your journey": "Matkallasi",
"Open": "Avata", "Open": "Avata",
"or": "tai", "or": "tai",
@@ -79,10 +83,13 @@
"Skip to main content": "Siirry pääsisältöön", "Skip to main content": "Siirry pääsisältöön",
"Something went wrong!": "Jotain meni pieleen!", "Something went wrong!": "Jotain meni pieleen!",
"Street": "Katu", "Street": "Katu",
"special character": "erikoishahmo",
"Total Points": "Kokonaispisteet", "Total Points": "Kokonaispisteet",
"Transaction date": "Tapahtuman päivämäärä", "Transaction date": "Tapahtuman päivämäärä",
"Transactions": "Tapahtumat", "Transactions": "Tapahtumat",
"to": "to",
"User information": "Käyttäjän tiedot", "User information": "Käyttäjän tiedot",
"uppercase letter": "iso kirjain",
"Visiting address": "Käyntiosoite", "Visiting address": "Käyntiosoite",
"Where should you go next?": "Minne sinun pitäisi mennä seuraavaksi?", "Where should you go next?": "Minne sinun pitäisi mennä seuraavaksi?",
"Year": "Vuosi", "Year": "Vuosi",

View File

@@ -8,6 +8,7 @@
"Book": "Bok", "Book": "Bok",
"Booking number": "Bestillingsnummer", "Booking number": "Bestillingsnummer",
"Cancel": "Avbryt", "Cancel": "Avbryt",
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Sjekk ut kredittkortene som er lagret på profilen din. Betal med et lagret kort når du er pålogget for en jevnere nettopplevelse.",
"City": "By", "City": "By",
"City/State": "By/Stat", "City/State": "By/Stat",
"Click here to log in": "Klikk her for å logge inn", "Click here to log in": "Klikk her for å logge inn",
@@ -21,6 +22,7 @@
"Country code": "Landskode", "Country code": "Landskode",
"Current level": "Nåværende nivå", "Current level": "Nåværende nivå",
"Current password": "Nåværende passord", "Current password": "Nåværende passord",
"characters": "tegn",
"Date of Birth": "Fødselsdato", "Date of Birth": "Fødselsdato",
"Day": "Dag", "Day": "Dag",
"Description": "Beskrivelse", "Description": "Beskrivelse",
@@ -45,6 +47,7 @@
"Month": "Måned", "Month": "Måned",
"My communication preferences": "Mine kommunikasjonspreferanser", "My communication preferences": "Mine kommunikasjonspreferanser",
"My credit cards": "Kredittkortene mine", "My credit cards": "Kredittkortene mine",
"My membership cards": "Mine medlemskort",
"My pages": "Mine sider", "My pages": "Mine sider",
"My wishes": "Mine ønsker", "My wishes": "Mine ønsker",
"New password": "Nytt passord", "New password": "Nytt passord",
@@ -55,6 +58,7 @@
"Not found": "Ikke funnet", "Not found": "Ikke funnet",
"night": "natt", "night": "natt",
"nights": "netter", "nights": "netter",
"number": "antall",
"On your journey": "På reisen din", "On your journey": "På reisen din",
"Open": "Åpen", "Open": "Åpen",
"or": "eller", "or": "eller",
@@ -79,10 +83,13 @@
"Skip to main content": "Gå videre til hovedsiden", "Skip to main content": "Gå videre til hovedsiden",
"Something went wrong!": "Noe gikk galt!", "Something went wrong!": "Noe gikk galt!",
"Street": "Gate", "Street": "Gate",
"special character": "spesiell karakter",
"Total Points": "Totale poeng", "Total Points": "Totale poeng",
"Transaction date": "Transaksjonsdato", "Transaction date": "Transaksjonsdato",
"Transactions": "Transaksjoner", "Transactions": "Transaksjoner",
"to": "til",
"User information": "Brukerinformasjon", "User information": "Brukerinformasjon",
"uppercase letter": "stor bokstav",
"Visiting address": "Besøksadresse", "Visiting address": "Besøksadresse",
"Where should you go next?": "Hvor bør du gå videre?", "Where should you go next?": "Hvor bør du gå videre?",
"Year": "År", "Year": "År",

View File

@@ -8,6 +8,7 @@
"Book": "Boka", "Book": "Boka",
"Booking number": "Bokningsnummer", "Booking number": "Bokningsnummer",
"Cancel": "Avbryt", "Cancel": "Avbryt",
"Check out the credit cards saved to your profile. Pay with a saved card when signed in for a smoother web experience.": "Kolla in kreditkorten som sparats i din profil. Betala med ett sparat kort när du är inloggad för en smidigare webbupplevelse.",
"City": "Ort", "City": "Ort",
"City/State": "Ort", "City/State": "Ort",
"Click here to log in": "Klicka här för att logga in", "Click here to log in": "Klicka här för att logga in",
@@ -21,6 +22,7 @@
"Country code": "Landskod", "Country code": "Landskod",
"Current level": "Nuvarande nivå", "Current level": "Nuvarande nivå",
"Current password": "Nuvarande lösenord", "Current password": "Nuvarande lösenord",
"characters": "tecken",
"Date of Birth": "Födelsedatum", "Date of Birth": "Födelsedatum",
"Day": "Dag", "Day": "Dag",
"Description": "Beskrivning", "Description": "Beskrivning",
@@ -45,6 +47,7 @@
"Month": "Månad", "Month": "Månad",
"My communication preferences": "Mina kommunikationspreferenser", "My communication preferences": "Mina kommunikationspreferenser",
"My credit cards": "Mina kreditkort", "My credit cards": "Mina kreditkort",
"My membership cards": "Mina medlemskort",
"My pages": "Mina sidor", "My pages": "Mina sidor",
"My wishes": "Mina önskningar", "My wishes": "Mina önskningar",
"New password": "Nytt lösenord", "New password": "Nytt lösenord",
@@ -55,6 +58,7 @@
"Not found": "Hittades inte", "Not found": "Hittades inte",
"night": "natt", "night": "natt",
"nights": "nätter", "nights": "nätter",
"number": "nummer",
"On your journey": "På din resa", "On your journey": "På din resa",
"Open": "Öppna", "Open": "Öppna",
"or": "eller", "or": "eller",
@@ -79,10 +83,13 @@
"Skip to main content": "Fortsätt till huvudinnehåll", "Skip to main content": "Fortsätt till huvudinnehåll",
"Something went wrong!": "Något gick fel!", "Something went wrong!": "Något gick fel!",
"Street": "Gata", "Street": "Gata",
"special character": "speciell karaktär",
"Total Points": "Total poäng", "Total Points": "Total poäng",
"Transaction date": "Transaktionsdatum", "Transaction date": "Transaktionsdatum",
"Transactions": "Transaktioner", "Transactions": "Transaktioner",
"to": "till",
"User information": "Användar information", "User information": "Användar information",
"uppercase letter": "stor bokstav",
"Visiting address": "Besöksadress", "Visiting address": "Besöksadress",
"Where should you go next?": "Vart ska du gå härnäst?", "Where should you go next?": "Vart ska du gå härnäst?",
"Year": "År", "Year": "År",