Add homepage subscription popup with promo video and phone signup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-07 20:43:16 +03:30
co-authored by Cursor
parent 25cab1104a
commit b3df0d3eb4
3 changed files with 257 additions and 0 deletions
Binary file not shown.
+2
View File
@@ -9,6 +9,7 @@ import FeaturesBar from "@/components/home/FeaturesBar";
import BlackCollection from "@/components/home/BlackCollection"; import BlackCollection from "@/components/home/BlackCollection";
import AboutSection from "@/components/home/AboutSection"; import AboutSection from "@/components/home/AboutSection";
import SiteFooter from "@/components/home/SiteFooter"; import SiteFooter from "@/components/home/SiteFooter";
import SubscribeModal from "@/components/home/SubscribeModal";
import { mapHomeSlots } from "@/lib/map-home-slots"; import { mapHomeSlots } from "@/lib/map-home-slots";
import { import {
fetchPublicWebsiteSlots, fetchPublicWebsiteSlots,
@@ -50,6 +51,7 @@ export default async function HomePage() {
return ( return (
<main className="home-page min-h-dvh bg-white text-ink"> <main className="home-page min-h-dvh bg-white text-ink">
<SubscribeModal />
<div className="relative"> <div className="relative">
<SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} /> <SiteHeader megaTabs={megaTabs.length ? megaTabs : home.megaTabs} />
<HeroSlider slides={home.heroSlides} /> <HeroSlider slides={home.heroSlides} />
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { FormEvent, useCallback, useEffect, useId, useState } from "react";
import { createPortal } from "react-dom";
import { API_URL } from "@/lib/public-api";
const HIDDEN_KEY = "mana-subscribe-hidden";
const LEGACY_DONE_KEY = "mana-subscribe-done";
const PROMO_VIDEO = "/videos/promo.mp4";
function isHiddenForever() {
try {
return (
window.localStorage.getItem(HIDDEN_KEY) === "1" ||
window.localStorage.getItem(LEGACY_DONE_KEY) === "1"
);
} catch {
return false;
}
}
function rememberHidden() {
try {
window.localStorage.setItem(HIDDEN_KEY, "1");
} catch {
/* storage unavailable */
}
}
const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
const ARABIC_DIGITS = "٠١٢٣٤٥٦٧٨٩";
function normalizeCell(value: string) {
return value
.trim()
.replace(/[۰-۹]/g, (digit) => String(PERSIAN_DIGITS.indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String(ARABIC_DIGITS.indexOf(digit)))
.replace(/[^\d]/g, "");
}
function apiErrorMessage(body: unknown) {
if (body && typeof body === "object" && "message" in body) {
const message = (body as { message: unknown }).message;
if (Array.isArray(message) && message.length) return String(message[0]);
if (typeof message === "string" && message.trim()) return message;
}
return "ثبت شماره ناموفق بود. دوباره تلاش کنید.";
}
export default function SubscribeModal() {
const titleId = useId();
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
const [phone, setPhone] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
setMounted(true);
if (isHiddenForever()) return;
const timer = window.setTimeout(() => setOpen(true), 600);
return () => window.clearTimeout(timer);
}, []);
const dismiss = useCallback(() => {
setOpen(false);
}, []);
const decline = useCallback(() => {
rememberHidden();
setOpen(false);
}, []);
useEffect(() => {
if (!open) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") dismiss();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previous;
window.removeEventListener("keydown", onKey);
};
}, [open, dismiss]);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const cellNumber = normalizeCell(phone);
if (!/^09\d{9}$/.test(cellNumber)) {
setError("شماره موبایل را با ۰۹ و ۱۱ رقم وارد کنید.");
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(`${API_URL}/public/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cellNumber }),
});
const body: unknown = await res.json().catch(() => null);
if (!res.ok) {
setError(apiErrorMessage(body));
return;
}
rememberHidden();
setDone(true);
} catch {
setError("ارتباط با سرور برقرار نشد. دوباره تلاش کنید.");
} finally {
setSaving(false);
}
}
if (!mounted || !open) return null;
return createPortal(
<div
className="fixed inset-0 z-[220] flex items-center justify-center p-4 sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<button
type="button"
className="absolute inset-0 bg-ink/45 backdrop-blur-sm"
aria-label="بستن"
onClick={dismiss}
/>
<div className="relative z-10 flex max-h-[min(94dvh,920px)] w-full max-w-[1080px] flex-col overflow-hidden rounded-[5px] bg-white shadow-[0_24px_80px_rgba(0,0,0,0.28)] md:h-[min(92dvh,880px)] md:w-auto md:max-w-[min(96vw,1080px)] md:flex-row">
<div className="relative aspect-[9/16] max-h-[42dvh] w-full shrink-0 bg-ink md:aspect-[9/16] md:h-full md:max-h-none md:w-auto">
<video
className="absolute inset-0 h-full w-full object-cover"
src={PROMO_VIDEO}
autoPlay
muted
loop
playsInline
/>
</div>
<div className="relative flex min-h-0 w-full flex-col justify-center overflow-y-auto px-6 py-8 text-right sm:px-10 sm:py-10 md:w-[420px] md:shrink-0">
<button
type="button"
aria-label="بستن"
onClick={dismiss}
className="absolute top-4 left-4 flex size-10 items-center justify-center rounded-full transition-opacity hover:opacity-60"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6 6l12 12M18 6L6 18"
stroke="#0a0a0a"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
{done ? (
<div>
<p
dir="ltr"
className="font-montserrat text-[11px] font-medium uppercase tracking-[0.22em] text-muted"
>
You are on the list
</p>
<h2
id={titleId}
className="font-doran mt-3 text-[clamp(1.6rem,2.4vw,2.15rem)] font-medium leading-tight text-ink"
>
شماره شما ثبت شد
</h2>
<p className="mt-4 text-sm font-light leading-8 text-ink/75">
از این پس خبر کالکشن‌های تازه و فروش ویژه مانا را برای همین
شماره می‌فرستیم.
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="mt-8 h-[52px] w-full rounded-[5px] bg-ink text-base font-light text-white transition-opacity hover:opacity-80"
>
بستن
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<p
dir="ltr"
className="font-montserrat text-[11px] font-medium uppercase tracking-[0.22em] text-muted"
>
Join Mana
</p>
<h2
id={titleId}
className="font-doran mt-3 text-[clamp(1.6rem,2.4vw,2.15rem)] font-medium leading-tight text-ink"
>
به جمع مانا بپیوندید
</h2>
<p className="mt-4 text-sm font-light leading-8 text-ink/75">
شماره موبایل‌تان را ثبت کنید تا از کالکشن‌های تازه، فروش ویژه و
خبرهای مانا باخبر شوید.
</p>
<label className="mt-8 block space-y-2">
<span className="text-xs text-muted">شماره موبایل</span>
<input
name="cellNumber"
type="tel"
inputMode="tel"
autoComplete="tel"
dir="ltr"
required
value={phone}
onChange={(event) => {
setPhone(event.target.value);
if (error) setError(null);
}}
placeholder="09xxxxxxxxx"
className="h-12 w-full rounded-[5px] border border-line bg-white px-3 text-left text-sm text-ink outline-none placeholder:text-[#818181] focus:border-ink"
/>
</label>
{error ? (
<p className="mt-3 text-sm text-ink" role="alert">
{error}
</p>
) : null}
<button
type="submit"
disabled={saving}
className="mt-5 h-[52px] w-full rounded-[5px] bg-ink text-base font-light text-white transition-opacity hover:opacity-80 disabled:opacity-50"
>
{saving ? "در حال ثبت..." : "ثبت شماره"}
</button>
<button
type="button"
onClick={decline}
className="mt-3 w-full py-1.5 text-center text-[13px] font-light text-muted transition-colors hover:text-ink"
>
مایل نیستم، دیگر این پیام را نشان نده
</button>
</form>
)}
</div>
</div>
</div>,
document.body,
);
}