在Next.js 15的 App Router中,当用户离开某个页面时,如何显示模态框或弹出窗口?

前端开发 2026-07-10

我正在用Next.js 15(App Router)构建一个发布平台,我有一个在发表文章后用户会到达的分享页(/share/[id])。当用户离开该页前往平台上的任意其他页面时,我想显示一个推广弹窗(自定义模态框,而不是浏览器原生的beforeunload对话框)。

问题在于,我还没搞清楚在App Router中检测“离开页面”的正确方法。

我试过的办法:

  1. 把模态框渲染在分享页内部,但在导航发生时组件会立刻卸载,因此模态框在用户看到它之前就被销毁了。
  2. beforeunload 事件,这个只在用户关闭标签页或刷新页面时才会触发。它不会在客户端导航时触发,而且无论如何只会显示原生的浏览器对话框(我需要的是自定义界面)。
  3. 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导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章