From 7561e996c66424d164595b539b539876f1b6c726 Mon Sep 17 00:00:00 2001 From: "Chuma Mcphoy (We Ahead)" Date: Mon, 25 Aug 2025 14:41:50 +0000 Subject: [PATCH] Merged in feat/LOY-311-New-Avatar-Component (pull request #2694) Feat(LOY-311) Create avatar design system component * feat(LOY-311): Creat & use New Avatar Design System Component * refactor(LOY-311): replace avatar used in app header with design system component * fix(LOY-311): use correct space vars Approved-by: Erik Tiekstra --- apps/scandic-web/.env.local.example | 1 + .../Overview/UserBaseInfo/index.tsx | 58 ++++++++ .../UserBaseInfo/userBaseInfo.module.css | 18 +++ .../Blocks/DynamicContent/Overview/index.tsx | 3 + .../Header/MainMenu/Avatar/avatar.module.css | 14 -- .../Header/MainMenu/Avatar/index.tsx | 28 ---- .../Header/MainMenu/MyPagesMenu/index.tsx | 2 +- .../MainMenu/MyPagesMenuWrapper/index.tsx | 2 +- .../MainMenu/MyPagesMobileMenu/index.tsx | 3 +- .../copyMembershipIdButton.module.css | 11 ++ .../MyPages/CopyMembershipIdButton/index.tsx | 56 ++++++++ apps/scandic-web/env/server.ts | 6 + .../lib/components/Avatar/Avatar.stories.tsx | 125 ++++++++++++++++++ .../lib/components/Avatar/avatar.module.css | 38 ++++++ .../lib/components/Avatar/index.tsx | 35 +++++ .../lib/components/Avatar/types.ts | 23 ++++ .../lib/components/Avatar/variants.ts | 20 +++ packages/design-system/package.json | 1 + .../public/img/profile-picture.png | Bin 0 -> 2697 bytes 19 files changed, 399 insertions(+), 45 deletions(-) create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Overview/UserBaseInfo/index.tsx create mode 100644 apps/scandic-web/components/Blocks/DynamicContent/Overview/UserBaseInfo/userBaseInfo.module.css delete mode 100644 apps/scandic-web/components/Header/MainMenu/Avatar/avatar.module.css delete mode 100644 apps/scandic-web/components/Header/MainMenu/Avatar/index.tsx create mode 100644 apps/scandic-web/components/MyPages/CopyMembershipIdButton/copyMembershipIdButton.module.css create mode 100644 apps/scandic-web/components/MyPages/CopyMembershipIdButton/index.tsx create mode 100644 packages/design-system/lib/components/Avatar/Avatar.stories.tsx create mode 100644 packages/design-system/lib/components/Avatar/avatar.module.css create mode 100644 packages/design-system/lib/components/Avatar/index.tsx create mode 100644 packages/design-system/lib/components/Avatar/types.ts create mode 100644 packages/design-system/lib/components/Avatar/variants.ts create mode 100644 packages/design-system/public/img/profile-picture.png diff --git a/apps/scandic-web/.env.local.example b/apps/scandic-web/.env.local.example index c1426f4e2..f16417451 100644 --- a/apps/scandic-web/.env.local.example +++ b/apps/scandic-web/.env.local.example @@ -54,6 +54,7 @@ GOOGLE_DYNAMIC_MAP_ID="" ENABLE_SURPRISES="true" ENABLE_DTMC="true" +ENABLE_NEW_OVERVIEW_SECTION="true" SHOW_SITE_WIDE_ALERT="false" diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Overview/UserBaseInfo/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Overview/UserBaseInfo/index.tsx new file mode 100644 index 000000000..1a1ae93cf --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Overview/UserBaseInfo/index.tsx @@ -0,0 +1,58 @@ +import { Avatar } from "@scandic-hotels/design-system/Avatar" +import { Typography } from "@scandic-hotels/design-system/Typography" + +import CopyMembershipIdButton from "@/components/MyPages/CopyMembershipIdButton" +import { getIntl } from "@/i18n" +import { getInitials } from "@/utils/user" + +import styles from "./userBaseInfo.module.css" + +import type { User } from "@scandic-hotels/trpc/types/user" + +interface UserBaseInfoProps { + user: User +} + +export default async function UserBaseInfo({ user }: UserBaseInfoProps) { + const intl = await getIntl() + const initials = getInitials(user.firstName, user.lastName) + + return ( +
+ +
+ +

+ {user.firstName} {user.lastName} +

+
+
+ + + {intl.formatMessage({ + defaultMessage: "Membership ID:", + })} + + + {user.membership?.membershipNumber ? ( + + ) : ( + + + {intl.formatMessage({ + defaultMessage: "N/A", + })} + + + )} +
+
+
+ ) +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Overview/UserBaseInfo/userBaseInfo.module.css b/apps/scandic-web/components/Blocks/DynamicContent/Overview/UserBaseInfo/userBaseInfo.module.css new file mode 100644 index 000000000..230ef4051 --- /dev/null +++ b/apps/scandic-web/components/Blocks/DynamicContent/Overview/UserBaseInfo/userBaseInfo.module.css @@ -0,0 +1,18 @@ +.container { + display: flex; + align-items: center; + gap: var(--Space-x15); +} + +.fullName { + color: var(--Text-Heading); + text-transform: capitalize; +} + +.membershipInfo { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--Space-x05); + color: var(--Scandic-Red-100); +} diff --git a/apps/scandic-web/components/Blocks/DynamicContent/Overview/index.tsx b/apps/scandic-web/components/Blocks/DynamicContent/Overview/index.tsx index 631f5c6e8..18682829b 100644 --- a/apps/scandic-web/components/Blocks/DynamicContent/Overview/index.tsx +++ b/apps/scandic-web/components/Blocks/DynamicContent/Overview/index.tsx @@ -16,6 +16,7 @@ import Hero from "./Friend/Hero" import MembershipNumber from "./Friend/MembershipNumber" import Friend from "./Friend" import Stats from "./Stats" +import UserBaseInfo from "./UserBaseInfo" import styles from "./overview.module.css" @@ -57,6 +58,8 @@ export default async function Overview({ + {env.ENABLE_NEW_OVERVIEW_SECTION ? : null} + {/*TODO: Replace Hero Section Cards with New ones. */} diff --git a/apps/scandic-web/components/Header/MainMenu/Avatar/avatar.module.css b/apps/scandic-web/components/Header/MainMenu/Avatar/avatar.module.css deleted file mode 100644 index 577b720d2..000000000 --- a/apps/scandic-web/components/Header/MainMenu/Avatar/avatar.module.css +++ /dev/null @@ -1,14 +0,0 @@ -.avatar { - display: flex; - justify-content: center; - align-items: center; - overflow: hidden; - border-radius: var(--Corner-radius-rounded); - width: 2rem; - height: 2rem; - background-color: var(--UI-Grey-40); -} - -.initials { - background-color: var(--Base-Icon-Low-contrast); -} diff --git a/apps/scandic-web/components/Header/MainMenu/Avatar/index.tsx b/apps/scandic-web/components/Header/MainMenu/Avatar/index.tsx deleted file mode 100644 index db3cf3a98..000000000 --- a/apps/scandic-web/components/Header/MainMenu/Avatar/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Footnote from "@scandic-hotels/design-system/Footnote" -import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" -import Image from "@scandic-hotels/design-system/Image" - -import styles from "./avatar.module.css" - -import type { AvatarProps } from "@/types/components/header/avatar" - -export default function Avatar({ image, initials }: AvatarProps) { - let classNames = [styles.avatar] - let element = - if (image) { - classNames.push(styles.image) - element = {image.alt} - } else if (initials) { - classNames.push(styles.initials) - element = ( - - {initials} - - ) - } - return ( - - {element} - - ) -} diff --git a/apps/scandic-web/components/Header/MainMenu/MyPagesMenu/index.tsx b/apps/scandic-web/components/Header/MainMenu/MyPagesMenu/index.tsx index 49bf553c7..9537fe7c9 100644 --- a/apps/scandic-web/components/Header/MainMenu/MyPagesMenu/index.tsx +++ b/apps/scandic-web/components/Header/MainMenu/MyPagesMenu/index.tsx @@ -3,6 +3,7 @@ import { useRef } from "react" import { useIntl } from "react-intl" +import { Avatar } from "@scandic-hotels/design-system/Avatar" import Body from "@scandic-hotels/design-system/Body" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer" @@ -13,7 +14,6 @@ import useClickOutside from "@/hooks/useClickOutside" import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import { getInitials } from "@/utils/user" -import Avatar from "../Avatar" import MainMenuButton from "../MainMenuButton" import MyPagesMenuContent, { useMyPagesNavigation } from "../MyPagesMenuContent" diff --git a/apps/scandic-web/components/Header/MainMenu/MyPagesMenuWrapper/index.tsx b/apps/scandic-web/components/Header/MainMenu/MyPagesMenuWrapper/index.tsx index b0d0e8aa3..a70b1168f 100644 --- a/apps/scandic-web/components/Header/MainMenu/MyPagesMenuWrapper/index.tsx +++ b/apps/scandic-web/components/Header/MainMenu/MyPagesMenuWrapper/index.tsx @@ -4,13 +4,13 @@ import { useSession } from "next-auth/react" import { useIntl } from "react-intl" import { MembershipLevelEnum } from "@scandic-hotels/common/constants/membershipLevels" +import { Avatar } from "@scandic-hotels/design-system/Avatar" import { trpc } from "@scandic-hotels/trpc/client" import LoginButton from "@/components/LoginButton" import useLang from "@/hooks/useLang" import { isValidClientSession } from "@/utils/clientSession" -import Avatar from "../Avatar" import MyPagesMenu, { MyPagesMenuSkeleton } from "../MyPagesMenu" import MyPagesMobileMenu, { MyPagesMobileMenuSkeleton, diff --git a/apps/scandic-web/components/Header/MainMenu/MyPagesMobileMenu/index.tsx b/apps/scandic-web/components/Header/MainMenu/MyPagesMobileMenu/index.tsx index d2ead5b65..ac9a7cb74 100644 --- a/apps/scandic-web/components/Header/MainMenu/MyPagesMobileMenu/index.tsx +++ b/apps/scandic-web/components/Header/MainMenu/MyPagesMobileMenu/index.tsx @@ -5,12 +5,13 @@ import { Dialog, Modal } from "react-aria-components" import { useIntl } from "react-intl" import { useMediaQuery } from "usehooks-ts" +import { Avatar } from "@scandic-hotels/design-system/Avatar" + import useDropdownStore from "@/stores/main-menu" import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" import { getInitials } from "@/utils/user" -import Avatar from "../Avatar" import MainMenuButton from "../MainMenuButton" import MyPagesMenuContent from "../MyPagesMenuContent" diff --git a/apps/scandic-web/components/MyPages/CopyMembershipIdButton/copyMembershipIdButton.module.css b/apps/scandic-web/components/MyPages/CopyMembershipIdButton/copyMembershipIdButton.module.css new file mode 100644 index 000000000..b35ca57ad --- /dev/null +++ b/apps/scandic-web/components/MyPages/CopyMembershipIdButton/copyMembershipIdButton.module.css @@ -0,0 +1,11 @@ +.container { + display: flex; + align-items: center; + gap: var(--Space-x1); + color: var(--Scandic-Red-100); +} + +.copyButton { + min-width: auto; + padding: var(--Space-x05); +} diff --git a/apps/scandic-web/components/MyPages/CopyMembershipIdButton/index.tsx b/apps/scandic-web/components/MyPages/CopyMembershipIdButton/index.tsx new file mode 100644 index 000000000..112e11673 --- /dev/null +++ b/apps/scandic-web/components/MyPages/CopyMembershipIdButton/index.tsx @@ -0,0 +1,56 @@ +"use client" + +import { useIntl } from "react-intl" + +import { Button } from "@scandic-hotels/design-system/Button" +import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { Typography } from "@scandic-hotels/design-system/Typography" + +import { toast } from "@/components/TempDesignSystem/Toasts" + +import styles from "./copyMembershipIdButton.module.css" + +interface CopyMembershipIdButtonProps { + membershipNumber: string +} + +export default function CopyMembershipIdButton({ + membershipNumber, +}: CopyMembershipIdButtonProps) { + const intl = useIntl() + + function handleCopy() { + try { + navigator.clipboard.writeText(membershipNumber) + toast.success( + intl.formatMessage({ + defaultMessage: "Membership ID copied to clipboard", + }) + ) + } catch { + toast.error( + intl.formatMessage({ + defaultMessage: "Failed to copy", + }) + ) + } + } + + return ( +
+ + + {membershipNumber} + + + +
+ ) +} diff --git a/apps/scandic-web/env/server.ts b/apps/scandic-web/env/server.ts index da3c39ac8..545725c9f 100644 --- a/apps/scandic-web/env/server.ts +++ b/apps/scandic-web/env/server.ts @@ -160,6 +160,11 @@ export const env = createEnv({ .refine((s) => s === "1" || s === "0") .transform((s) => s === "1") .default("1"), + ENABLE_NEW_OVERVIEW_SECTION: z + .string() + .refine((s) => s === "true" || s === "false") + .transform((s) => s === "true") + .default("false"), }, emptyStringAsUndefined: true, runtimeEnv: { @@ -243,6 +248,7 @@ export const env = createEnv({ DTMC_ENTRA_ID_SECRET: process.env.DTMC_ENTRA_ID_SECRET, CAMPAIGN_PAGES_ENABLED: process.env.CAMPAIGN_PAGES_ENABLED, WEBVIEW_SHOW_OVERVIEW: process.env.WEBVIEW_SHOW_OVERVIEW, + ENABLE_NEW_OVERVIEW_SECTION: process.env.ENABLE_NEW_OVERVIEW_SECTION, }, }) diff --git a/packages/design-system/lib/components/Avatar/Avatar.stories.tsx b/packages/design-system/lib/components/Avatar/Avatar.stories.tsx new file mode 100644 index 000000000..27172b478 --- /dev/null +++ b/packages/design-system/lib/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' + +import { Avatar } from '.' +import { config } from './variants' + +const meta: Meta = { + title: 'Components/Avatar', + component: Avatar, + parameters: { + layout: 'centered', + }, + argTypes: { + size: { + control: { type: 'select' }, + options: Object.keys(config.variants.size), + }, + }, +} + +export default meta + +type Story = StoryObj + +export const WithImage: Story = { + args: { + src: `../../../public/img/profile-picture.png`, + alt: 'Profile photo', + size: 'md', + }, +} + +export const WithInitials: Story = { + args: { + initials: 'FR', + size: 'md', + }, +} + +export const Fallback: Story = { + args: { + size: 'md', + }, +} + +export const SmallSize: Story = { + render: () => ( +
+ + + +
+ ), +} + +export const MediumSize: Story = { + render: () => ( +
+ + + +
+ ), +} + +export const LargeSize: Story = { + render: () => ( +
+ + + +
+ ), +} + +export const AllSizes: Story = { + render: () => ( +
+
+ + Small (20px) +
+
+ + Medium (32px) +
+
+ + Large (55px) +
+
+ ), +} diff --git a/packages/design-system/lib/components/Avatar/avatar.module.css b/packages/design-system/lib/components/Avatar/avatar.module.css new file mode 100644 index 000000000..5d9677d69 --- /dev/null +++ b/packages/design-system/lib/components/Avatar/avatar.module.css @@ -0,0 +1,38 @@ +.avatar { + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + border-radius: var(--Corner-radius-rounded); + background-color: var(--Overlay-40); +} + +.avatar:has(.initials) { + background-color: var(--Icon-Accent); +} + +.avatar img { + object-fit: cover; + width: 100%; + height: 100%; +} + +.size-sm { + width: 24px; + height: 24px; +} + +.size-md { + width: 32px; + height: 32px; +} + +.size-lg { + width: 55px; + height: 55px; +} + +.initials { + color: white; + text-transform: uppercase; +} diff --git a/packages/design-system/lib/components/Avatar/index.tsx b/packages/design-system/lib/components/Avatar/index.tsx new file mode 100644 index 000000000..b7edcb89d --- /dev/null +++ b/packages/design-system/lib/components/Avatar/index.tsx @@ -0,0 +1,35 @@ +import { MaterialIcon } from '../Icons/MaterialIcon' +import { Typography } from '../Typography' +import Image from '../Image' + +import { variants } from './variants' +import type { AvatarProps } from './types' + +export function Avatar({ + src, + alt, + initials, + size = 'md', + className, +}: AvatarProps) { + const classNames = variants({ size, className }) + const pixelSize = size === 'sm' ? 24 : size === 'md' ? 32 : 55 + const iconSize = size === 'sm' ? 16 : 24 + + return ( +
+ {src ? ( + {alt + ) : initials ? ( + + {initials} + + ) : ( + + )} +
+ ) +} diff --git a/packages/design-system/lib/components/Avatar/types.ts b/packages/design-system/lib/components/Avatar/types.ts new file mode 100644 index 000000000..4a821a306 --- /dev/null +++ b/packages/design-system/lib/components/Avatar/types.ts @@ -0,0 +1,23 @@ +export interface AvatarProps { + /** + * The URL of the image to display + */ + src?: string + /** + * Alt text for the image (for accessibility) + */ + alt?: string + /** + * Initials to display when no image is provided + */ + initials?: string | null + /** + * Size of the avatar + * @default 'md' + */ + size?: 'sm' | 'md' | 'lg' + /** + * Additional CSS class names + */ + className?: string +} diff --git a/packages/design-system/lib/components/Avatar/variants.ts b/packages/design-system/lib/components/Avatar/variants.ts new file mode 100644 index 000000000..58888eb17 --- /dev/null +++ b/packages/design-system/lib/components/Avatar/variants.ts @@ -0,0 +1,20 @@ +import { cva } from 'class-variance-authority' + +import styles from './avatar.module.css' + +export const config = { + variants: { + size: { + sm: styles['size-sm'], + md: styles['size-md'], + lg: styles['size-lg'], + }, + }, + defaultVariants: { + size: 'md', + }, +} as const + +export const variants = Object.assign(cva(styles.avatar, config), { + initials: styles.initials, +}) diff --git a/packages/design-system/package.json b/packages/design-system/package.json index a4da7d289..cec3025f3 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -6,6 +6,7 @@ "exports": { "./Accordion": "./lib/components/Accordion/index.tsx", "./Accordion/AccordionItem": "./lib/components/Accordion/AccordionItem/index.tsx", + "./Avatar": "./lib/components/Avatar/index.tsx", "./BackToTopButton": "./lib/components/BackToTopButton/index.tsx", "./Body": "./lib/components/Body/index.tsx", "./BookingCodeChip": "./lib/components/BookingCodeChip/index.tsx", diff --git a/packages/design-system/public/img/profile-picture.png b/packages/design-system/public/img/profile-picture.png new file mode 100644 index 0000000000000000000000000000000000000000..6eff6bda268b0f3321685b6a26904aac9856d775 GIT binary patch literal 2697 zcmV;43U>90P)Rd;D1M?#z5MJ@;FOHYEteM3eE% ze!KU4_nv#s9Sa}x7~I-hu3l@?%_F-8iog_oh@lNHUzkB@- z|Kt3w_0fT^D=ItwmYrB%gR%b`wP=@S7M z+`6^^%h{kTtIy$<%%Kv&zKB zBY?pN*A|gd1CC=C6&T#+LRTT%;6TZbBazLb7AA@ls*!`%Rj%W%W=K!j^`FFrcyZ$GL0Ta4o={a$KS-^;R?z@h|VU3<;&)x z_twiWI$j|Jg3|H}8WBNiuF(lIpcMfH%n(XR=&W1yofn5b6u|BWR}?+l8?arsXxkox zE6mM%crpjr@Tfqq%35a;^Qif2kza0hKy@E!L&#`+p4qIC=#MFBZ&G2Hg*;N zn}JYofMvswj;F_34-SrD)zXEyvMqg#&H(a_dO!gteE3w=i9<;ik`?i|1cFg0AyBvHZ)o zW7*9%$`h0L-F=&I<(g}7;;^O8MGU+U)L<<;Hh%bDI2|{*n@wt^) zCR5U~P<9~>bT133*NDgn@q{wAs2+htaScburcpSr8@r!+6joTp$g6+Fr7Nz+Z~C9b zRbRRd*I!&Ty8}C6yb>YB(!F!Cg3;0_YzK9D)>40E+A|a)2GoWc+m&sc=x~i9sxB*; z?MP;VB%ttQkk5FS;o_$T{)V?E0}TA-j|ln2wzJx?dBZ(GrWsA_f}m1Hqo*(@<-n%k z>^)k;^vMttM<)yqgm+40I?zT%r6ollAb$r@mm*_}?~4KnX!EQR-GYT#Oi*ff5B(E& z+;{`7xO_FqX<_`}0fgy1q7#R4fO^p0oJU*@QDvGt@_D>7qw$BG?;_)POgl>|B?$T3 z)C1u`6i}k0ItVLHiUO{HCpJ5DvE48L}C67#bTrfW6q z8zr!}PvX1Rb)cG3hV_;f#!IlI?v~6#EP^C{!K;+OkBv^lXt~&JhU}hT@tU(9UOwdG z*3}EK`FnR_^3}Z<+xh^4eXl@Teb2r_ShA=YrSURupOeS0a~{6f+K88)ID`Y6_rW8G zh_Zaj3JidFRv!`r$R|Cmk~o-AC8i^me@y4)tImQ=Q@LiLi_-8ER$W-c*T267_{JT$ z_Q8jt#z%3$-x-f-oGt!QY?;lz`d-DLe7p z8}H#B3Q&jN>>AEmU{Usw}P9*%ZDv6REF>9q~$16#Z zO3@P>bBBvAo(F%bf`^{`2Ras~(b3+F<(IVJvi3IYzwa4*L0Za5L1rb7KH@TqEp$bS zIz|He2p!ema}pd)%+9T7wj>lhhUEO1wfhO%mpd^Gn{qojI)=*lH0GW&7agDKK*!ky zT+rg6=h~%c_G(yiMJM)e9mF|pd92A~F;cBbOHo7>0tU&l94VWBGmxJsOug#E5$0M^1=OfwUOI>KR$mH`Clk-S0XIkUvg;Fam1LE`Nex8I2t3V{%wbo!G$^jX zL{jgJv4FcDH8Vje2(uecn~3D-xogN*vDHJBD#>Uqq|!;9xd~Gyr!_lnOW;J56J|H8 z)~vRR`0?``B|XnfRQWprOHd)mfMgt3cFBFK(zCw5|D`Q|az)ezkO_SZ4$vN9VeJ|Gb-?=%bEz(zzavyWb^g*X_x1b!hrTI)