在Next.js 15的 App Router中,当用户离开某个页面时,如何显示模态框或弹出窗口?
我正在用Next.js 15(App Router)构建一个发布平台,我有一个在发表文章后用户会到达的分享页(/share/[id])。当用户离开该页前往平台上的任意其他页面时,我想显示一个推广弹窗(自定义模态框,而不是浏览器原生的beforeunload对话框)。
问题在于,我还没搞清楚在App Router中检测“离开页面”的正确方法。
我试过的办法:
- 把模态框渲染在分享页内部,但在导航发生时组件会立刻卸载,因此模态框在用户看到它之前就被销毁了。
beforeunload事件,这个只在用户关闭标签页或刷新页面时才会触发。它不会在客户端导航时触发,而且无论如何只会显示原生的浏览器对话框(我需要的是自定义界面)。useEffect清理函数,我原本以为可以检测到卸载并以某种方式显示模态框,但清理执行时组件已经从DOM中消失了。
我的布局结构看起来是:
app/
[lang]/
(dashboard)/
layout.jsx
share/
[id]/
page.jsx ← the page I want to detect exit from
feed/
page.jsx
settings/
page.jsx
组件代码:
"use client";
import React from "react";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Check, Zap, Search, Pen, BadgeCheck } from "lucide-react";
interface NudgeUserProps {
open: boolean;
onOpenChange: (open: boolean) => void;
articleCount?: number;
onSubscribe?: () => void;
onDismiss?: () => void;
}
const benefits = [
{
icon: Zap,
text: "100 ضعف المشاهدات لمقالاتك",
iconColor: "text-yellow-800",
},
{
icon: Search,
text: "الظهور في اقتراحات المنصة ونتائج البحث",
iconColor: "text-gray-800",
},
{
icon: Pen,
text: "أدوات رفيق كتابة للتصميم والتحرير دون حدود",
iconColor: "text-gray-800",
},
{
icon: BadgeCheck,
text: "توثيق حسابك ضمن قائمة الكتّاب المميّزين",
iconColor: "text-gray-800",
},
];
function NudgeContent({
articleCount = 3,
onSubscribe,
onDismiss,
}: Pick<NudgeUserProps, "articleCount" | "onSubscribe" | "onDismiss">) {
return (
<div className="flex flex-col items-center text-center px-4 pb-6 pt-2" dir="rtl">
<Badge className="rounded-full self-start bg-[#d2e2fa] text-[#3544bf] font-bold px-4 py-1.5 text-sm mb-4">
<Check className="w-4 h-4" />
مقال رقم {articleCount}
</Badge>
<h2 className="text-2xl font-bold mb-2 self-start">
نشرت {articleCount} مقالات!
</h2>
<p className="text-muted-foreground text-sm mb-6 max-w-xs text-right text-gray-800 self-start">
أنت ضمن أنشط 10% من الكتّاب على منصة كتابة - باقة الكاتب تناسبك
وتمنحك:
</p>
<div className="w-full space-y-0">
{benefits.map((benefit, index) => (
<div
key={index}
className="flex items-center justify-between gap-3 py-3.5 border-b border-border last:border-b-0 px-2 "
>
<span className="text-sm font-medium text-right flex-1">
{benefit.text}
</span>
<benefit.icon className={`w-10 h-10 shrink-0 bg-[#d2e2fa] p-2 rounded-[11px] ${benefit.iconColor}`} />
</div>
))}
</div>
<Button
onClick={onSubscribe}
className="w-full mt-6 bg-brandNew hover:bg-brandNew/80 text-white rounded-full h-14 text-lg font-bold"
>
انضم إلى باقة الكاتب
</Button>
<button
onClick={onDismiss}
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
لاحقاً
</button>
</div>
);
}
export default function NudgeUser({
open,
onOpenChange,
articleCount = 3,
onSubscribe,
onDismiss,
}: NudgeUserProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
const handleDismiss = () => {
onDismiss?.();
onOpenChange(false);
};
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md p-6 rounded-2xl">
<DialogTitle className="sr-only">باقة الكاتب</DialogTitle>
<DialogDescription className="sr-only">
انضم إلى باقة الكاتب للحصول على مزايا إضافية
</DialogDescription>
<NudgeContent
articleCount={articleCount}
onSubscribe={onSubscribe}
onDismiss={handleDismiss}
/>
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent>
<DrawerTitle className="sr-only">باقة الكاتب</DrawerTitle>
<DrawerDescription className="sr-only">
انضم إلى باقة الكاتب للحصول على مزايا إضافية
</DrawerDescription>
<NudgeContent
articleCount={articleCount}
onSubscribe={onSubscribe}
onDismiss={handleDismiss}
/>
</DrawerContent>
</Drawer>
);
}
解决方案
让模态框默认打开
你的组件被 open 状态完全控制。对于这个用例,让它支持一个默认打开的内部状态,同时在你需要时仍然允许自定义逻辑来关闭它。
抽象解决方案
"use client";
import React from "react";
import { useMediaQuery } from "@/hooks/use-media-query";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";
interface NudgeUserProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function NudgeUser({
open,
defaultOpen = true,
onOpenChange,
}: NudgeUserProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
// this is only used when parent does NOT control open
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
// if open exists, parent is controlling it
const isControlled = open !== undefined;
// choose which value to use
const modalOpen = isControlled ? open : internalOpen;
const handleOpenChange = (nextOpen: boolean) => {
// update internal state only if uncontrolled
if (!isControlled) {
setInternalOpen(nextOpen);
}
// always tell parent if callback exists
onOpenChange?.(nextOpen);
};
if (isDesktop) {
return (
<Dialog open={modalOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogTitle>Writer Plan</DialogTitle>
<DialogDescription>
Upgrade your account to get more benefits.
</DialogDescription>
<button onClick={() => handleOpenChange(false)}>Close</button>
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={modalOpen} onOpenChange={handleOpenChange}>
<DrawerContent>
<DrawerTitle>Writer Plan</DrawerTitle>
<DrawerDescription>
Upgrade your account to get more benefits.
</DrawerDescription>
<button onClick={() => handleOpenChange(false)}>Close</button>
</DrawerContent>
</Drawer>
);
}
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。