feat: add modal when user first visit

This commit is contained in:
viphan007
2025-04-10 15:16:06 +07:00
committed by Victorien Gauch
parent 9a91eeb507
commit 2f98f991bf
11 changed files with 303 additions and 6 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -2,11 +2,15 @@
import OnRamperWidget from "@/components/onramper";
import styles from "./page.module.scss";
import useFirstVisitModal from "@/hooks/useFirstVisitModal";
export default function Page() {
const modal = useFirstVisitModal({ type: "buy" });
return (
<section className={styles["content-wrapper"]}>
<OnRamperWidget />
{modal}
</section>
);
}

View File

@@ -2,11 +2,15 @@
import BridgeLayout from "@/components/bridge/bridge-layout";
import styles from "./page.module.scss";
import useFirstVisitModal from "@/hooks/useFirstVisitModal";
export default function Home() {
const modal = useFirstVisitModal({ type: "native-bridge" });
return (
<section className={styles["content-wrapper"]}>
<BridgeLayout />
{modal}
</section>
);
}

View File

@@ -1,12 +1,16 @@
"use client";
import useFirstVisitModal from "@/hooks/useFirstVisitModal";
import styles from "./page.module.scss";
import { Widget } from "@/components/lifi/widget";
export default function Page() {
const modal = useFirstVisitModal({ type: "all-bridges" });
return (
<section className={styles["content-wrapper"]}>
<Widget />
{modal}
</section>
);
}

View File

@@ -0,0 +1,16 @@
<svg viewBox="0 0 115 45" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.7521 31.3524H24.1938V9.17065H28.7521V31.3524Z" fill="currentColor" />
<path
d="M45.2229 8.73523C47.7392 8.73523 49.7949 9.61755 51.3877 11.3822C52.9781 13.1468 53.7757 15.3904 53.7757 18.1107V31.3501H49.2174V18.8051C49.2174 17.0978 48.6674 15.6861 47.5673 14.5723C46.4673 13.4585 45.1083 12.9016 43.488 12.9016C41.6638 12.9016 40.1742 13.4585 39.0168 14.5723C37.8595 15.6861 37.2797 17.0978 37.2797 18.8051V31.3501H32.7214V9.17066H37.2797V14.9C37.887 12.991 38.8931 11.4853 40.2979 10.3853C41.7005 9.28525 43.3437 8.73523 45.2252 8.73523H45.2229Z"
fill="currentColor" />
<path
d="M67.9867 8.73523C71.5457 8.73523 74.4402 10.0094 76.6678 12.5556C78.8953 15.104 79.8074 18.1841 79.4018 21.8027H61.0404C61.3589 23.5972 62.184 25.0524 63.5155 26.1662C64.847 27.28 66.4512 27.8369 68.3327 27.8369C69.7788 27.8369 71.0736 27.4817 72.2172 26.7735C73.3608 26.0654 74.25 25.1028 74.8871 23.8859L78.7074 25.5795C77.7243 27.461 76.3194 28.9644 74.4975 30.0942C72.6756 31.2241 70.5763 31.7878 68.2021 31.7878C64.8745 31.7878 62.0808 30.6878 59.8235 28.49C57.5661 26.2922 56.4363 23.5559 56.4363 20.2856C56.4363 17.0153 57.5432 14.2744 59.757 12.0583C61.9708 9.84443 64.7118 8.73752 67.9844 8.73752L67.9867 8.73523ZM67.9867 12.6862C66.3068 12.6862 64.8538 13.1995 63.6232 14.2285C62.3925 15.2552 61.5744 16.5936 61.171 18.2437H74.8023C74.4264 16.5936 73.622 15.2552 72.3937 14.2285C71.163 13.2018 69.694 12.6862 67.9867 12.6862Z"
fill="currentColor" />
<path
d="M92.0042 8.73523C94.6672 8.73523 96.8375 9.42963 98.515 10.8184C100.193 12.2072 101.034 14.2056 101.034 16.809V31.3524H96.4754V25.4489C95.1141 29.6749 92.38 31.7878 88.2709 31.7878C86.3596 31.7878 84.7692 31.2172 83.495 30.0736C82.2207 28.93 81.5859 27.4335 81.5859 25.5818C81.5859 23.4115 82.3514 21.7821 83.8868 20.6981C85.42 19.6141 87.3749 18.9403 89.7468 18.6791L94.0438 18.2437C95.6641 18.0993 96.4754 17.3751 96.4754 16.0734C96.4754 15.0031 96.0629 14.1483 95.2378 13.5135C94.4128 12.8764 93.3357 12.5579 92.0042 12.5579C90.6727 12.5579 89.4924 12.9062 88.5528 13.6006C87.6132 14.295 87.0128 15.3079 86.7515 16.6394L82.2368 15.5532C82.6424 13.47 83.7264 11.813 85.4934 10.5824C87.258 9.354 89.4283 8.73752 92.0042 8.73752V8.73523ZM89.7468 28.0959C91.6283 28.0959 93.2188 27.4656 94.5205 26.2075C95.8222 24.9493 96.4731 23.4803 96.4731 21.8004V20.5858C96.127 21.1656 95.286 21.5254 93.9545 21.6698L89.7445 22.19C88.6445 22.3367 87.7691 22.6598 87.1182 23.1663C86.4673 23.6728 86.1419 24.3603 86.1419 25.2289C86.1419 26.0975 86.4673 26.7918 87.1182 27.3121C87.7691 27.8323 88.6445 28.0936 89.7445 28.0936L89.7468 28.0959Z"
fill="currentColor" />
<path d="M21.0542 31.3524H0V9.17065H4.81724V27.0554H21.0542V31.3524Z" fill="currentColor" />
<path
d="M105.331 8.73514C107.704 8.73514 109.628 6.8113 109.628 4.43813C109.628 2.06495 107.704 0.141113 105.331 0.141113C102.958 0.141113 101.034 2.06495 101.034 4.43813C101.034 6.8113 102.958 8.73514 105.331 8.73514Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,103 @@
.modal-inner {
font-family: var(--font-atyp-text);
padding: 1.5rem;
}
.header-wrapper {
position: relative;
height: 11.2rem;
background-color: var(--v2-color-cyan);
display: flex;
.title {
color: var(--v2-color-navy);
line-height: 1.4;
font-size: 1.5rem;
width: 12.2rem;
}
.logo {
width: 2.6rem;
margin-bottom: 0.5rem;
height: auto;
color: var(--v2-color-navy);
}
.illustration {
position: absolute;
right: 0;
bottom: 0;
height: 100%;
width: auto;
}
.header-content {
padding: 1.25rem 1.5rem;
align-self: flex-end;
}
.close-icon {
display: inline-flex;
background: var(--v2-color-white);
border-radius: 50%;
padding: 0.25rem;
position: absolute;
right: 0.75rem;
top: 0.75rem;
cursor: pointer;
svg {
color: var(--v2-color-black);
width: 1rem;
height: 1rem;
}
}
}
.description {
font-size: 1rem;
line-height: 1.4;
margin-bottom: 1rem;
}
.how,
.extra {
font-size: 1rem;
text-align: center;
margin-bottom: 1rem;
}
.list {
display: flex;
flex-direction: column;
list-style-type: none;
gap: 0.5rem;
margin-bottom: 1rem;
li {
padding: 0.5rem 1rem;
background-color: var(--v2-color-light-pink);
border-radius: 0.625rem;
display: flex;
align-items: center;
gap: 0.625rem;
font-size: 0.875rem;
line-height: 1.4;
.order {
display: block;
flex-shrink: 0;
align-self: flex-start;
color: var(--v2-color-white);
background-color: var(--v2-color-indigo);
width: 1.5rem;
height: 1.5rem;
line-height: 1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-top: 0.1rem;
}
}
}

View File

@@ -0,0 +1,69 @@
import Modal from "@/components/modal";
import styles from "./first-time-visit.module.scss";
import Button from "@/components/ui/button";
import Image from "next/image";
import LineaIcon from "@/assets/logos/linea.svg";
import CloseIcon from "@/assets/icons/close.svg";
export type FirstTimeModalDataType = {
title: string;
description: string;
steps: string[];
btnText: string;
extraText?: string;
image: {
src: string;
width: number;
height: number;
};
};
type Props = {
isModalOpen: boolean;
onCloseModal: () => void;
data: FirstTimeModalDataType;
};
export default function FirstTimeVisit({ isModalOpen, onCloseModal, data }: Props) {
const { title, description, steps, btnText, extraText, image } = data;
const modalHeader = (
<div className={styles["header-wrapper"]}>
<Image
className={styles.illustration}
src={image.src}
width={image.width}
height={image.height}
role="presentation"
alt="modal image"
/>
<div className={styles["close-icon"]} onClick={onCloseModal} role="button">
<CloseIcon />
</div>
<div className={styles["header-content"]}>
<LineaIcon className={styles.logo} />
<h3 className={styles.title}>{title}</h3>
</div>
</div>
);
return (
<Modal title={title} isOpen={isModalOpen} onClose={onCloseModal} modalHeader={modalHeader}>
<div className={styles["modal-inner"]}>
<p className={styles.description}>{description}</p>
<p className={styles.how}>How it works:</p>
<ol className={styles.list}>
{steps.map((step, index) => (
<li key={index}>
<span className={styles.order}>{index + 1}</span>
<span>{step}</span>
</li>
))}
</ol>
{extraText && <p className={styles.extra}>{extraText}</p>}
<Button fullWidth onClick={onCloseModal}>
{btnText}
</Button>
</div>
</Modal>
);
}

View File

@@ -12,9 +12,10 @@ type Props = {
title: string;
isDrawer?: boolean;
size?: "md" | "lg";
modalHeader?: JSX.Element;
};
const Modal = ({ isOpen, onClose, children, title, isDrawer = false, size = "md" }: Props) => {
const Modal = ({ isOpen, onClose, children, title, isDrawer = false, size = "md", modalHeader }: Props) => {
const [mounted, setMounted] = useState<boolean>(false);
useEffect(() => {
@@ -65,12 +66,14 @@ const Modal = ({ isOpen, onClose, children, title, isDrawer = false, size = "md"
transition={{ duration: 0.2 }}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.heading}>
<div className={styles.title}>{title}</div>
<div className={styles["close-icon"]} onClick={onClose} role="button">
<CloseIcon />
{modalHeader || (
<div className={styles.heading}>
<div className={styles.title}>{title}</div>
<div className={styles["close-icon"]} onClick={onClose} role="button">
<CloseIcon />
</div>
</div>
</div>
)}
{children}
</motion.div>
</motion.div>

View File

@@ -14,6 +14,7 @@
.content {
border-radius: 0.625rem;
overflow: hidden;
position: relative;
top: -5rem;
color: var(--v2-color-black);

View File

@@ -0,0 +1,91 @@
import FirstTimeVisitModal, { FirstTimeModalDataType } from "@/components/modal/first-time-visit";
import { useState, useEffect, useMemo } from "react";
type Props = {
type: "all-bridges" | "native-bridge" | "buy";
};
const modalData: Record<string, FirstTimeModalDataType> = {
"all-bridges": {
title: "Welcome to the Linea Bridge!",
description: "Move your funds to Linea through the fastest route, at the lowest cost, and with no extra fees!",
steps: [
"Select your source chain & token",
"Choose Linea as your destination",
"Enter the amount & get the best rate",
"Connect your wallet & bridge",
"Your funds land on Linea in seconds",
],
btnText: "Start bridging now",
extraText: "Ready to bridge?",
image: {
src: "/images/illustration/bridge-first-time-modal-illustration.svg",
width: 128,
height: 179,
},
},
"native-bridge": {
title: "Welcome to the Native Bridge!",
description:
"Ethereum to Linea using Lineas official bridge. No third parties, no extra fees—just a direct way to move your assets.",
steps: [
"Select the token and amount you want to bridge from Ethereum to Linea.",
"Connect your wallet & approve",
"Confirm and wait - your funds land on Linea in about 20 minutes",
],
btnText: "Start bridging now",
extraText: "Ready to bridge?",
image: {
src: "/images/illustration/bridge-first-time-modal-illustration.svg",
width: 128,
height: 179,
},
},
buy: {
title: "Fund Your Linea Wallet",
description:
"Buy tokens instantly at the best rates and with no extra fees. We compare multiple providers to find you the best rates and fastest transactions.",
steps: [
"Pick a token & amount",
"Select a payment method (card, bank, etc.) and follow the instruction",
"Connect your wallet",
"Confirm & receive tokens in seconds",
],
btnText: "Buy tokens now",
image: {
src: "/images/illustration/buy-first-time-modal-illustration.svg",
width: 157,
height: 167,
},
},
};
const useFirstVisitModal = ({ type }: Props) => {
const [showModal, setShowModal] = useState(false);
const [shouldRenderModal, setShouldRenderModal] = useState(false);
const data = useMemo(() => modalData[type], [type]);
useEffect(() => {
if (typeof window !== "undefined" && localStorage.getItem(`hasVisited-${type}`) !== "true") {
setShowModal(true);
setShouldRenderModal(true);
}
}, [type]);
const closeModal = () => {
setShowModal(false);
setTimeout(() => {
setShouldRenderModal(false);
}, 300);
if (typeof window !== "undefined") {
localStorage.setItem(`hasVisited-${type}`, "true");
}
};
return shouldRenderModal ? (
<FirstTimeVisitModal isModalOpen={showModal} onCloseModal={closeModal} data={data} />
) : null;
};
export default useFirstVisitModal;