First CH Designs
PGE-10Pages

送信完了(サンクス)ページ

問い合わせ送信後に着地する独立ページ。祝辞ではなく「いつまでに返信するか」を最上部に置き、受付時刻から営業カレンダー(平日10:00–19:00・土日祝は次の営業日)で返信期限を実時間で算出して絶対時刻で表示する。受付番号の控え(コピー可)、返信が届かないときの導線、待っている間に見てほしいものを3件だけ。計測タグをURL到達で発火させる前提の設計で、onConversion prop をセッション内1回だけ呼ぶ。角丸・スクロール出現アニメ・英字キッカーなし、画像素材ゼロ・依存ゼロ。

追加日:
2026-09-23
依存:
なし
tags
#page #form #thanks #conversion #business-hours #no-image #no-dependency

プレビュー

送信完了

お問い合わせを受け付けました

内容は担当者が直接読みます。自動返信ではなく、人が書いた返信をお送りします。折り返しの期限は下記のとおりです。

ご返信の期限

--月--日(-) --:--まで

受付日時
--月--日(-) --:--
営業時間
平日 10:00–19:00
休業日
土日祝。受付が時間外の場合は次の営業日から数えます。

受付の控え

CT-————————-————

お電話でのご確認や、追加のご連絡をいただく際にこの番号をお伝えください。この画面は控えとしてそのまま閉じていただいて構いません。

返信先
you@example.com
ご用件
サイトの新規制作について

期限までに返信が届かないとき

迷惑メールフォルダに振り分けられている場合があります。見当たらないときは、お手数ですがお電話ください。受付番号をお伝えいただければ、その場で状況をお調べします。

050-0000-0000平日 10:00–19:00

お待ちいただく間に

ご案内は 3 件までにしています
  • 業種・規模・ご予算帯から近い事例を探せます。公開許可をいただいた案件のみ掲載しています。

  • 費用の考え方、着手から公開までの期間、公開後の更新作業について。ご相談前の確認にどうぞ。

  • お見積りの内訳、ご用意いただく素材、当社と御社の分担を1枚にまとめた資料です。

トップページへ戻る

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["500", "600", "700"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * 送信完了(サンクス)ページ
 * 既存のフォーム系は「入力させる」段(送信パネルは結果を数行で知らせるだけ)、CTA系は
 * 「押させる」段しか持っておらず、押した後に着地する画面が1件も無い。本ブロックは送信後の
 * 独立ページで、主役を祝辞ではなく「いつまでに返信するか」に置き、受付時刻から営業カレンダーで
 * 返信期限を実時間で算出して最上部に据える。控え(受付番号・返信先・種別)と、待っている間に
 * 見てほしいものを3つだけ、という情報量の上限が構造上の違い。飽和idiomの英字キッカーと
 * 末尾の「→」リンクは使わず、角丸とスクロール出現アニメもゼロ。掲載文・番号はすべて架空。
 * 画像素材ゼロ・依存ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **サンクスは独立URLに切る(/contact/thanks/ 等)**: 計測タグ(GA4のコンバージョン、広告の
 *   コンバージョンタグ)は「そのURLに到達したこと」を条件に発火させるのが最も壊れにくい。
 *   同一URLのままDOMを差し替えるとページビューが発火せず、広告側のタグマネージャからは
 *   「送信が1件も無い」ように見える。フォームのページとは別のパスを用意し、送信成功後に
 *   遷移させる。逆に、直リンクや再読み込みで誰でも到達できるURLになるため、金額や
 *   個人情報をこの画面のHTMLへ書かない(控えは受付番号までにとどめる)。
 * - **タグは1回だけ発火させる**: 戻るボタン・リロードで同じURLに何度も到達しうるので、
 *   発火済みフラグを sessionStorage に持ち、二重計上を防ぐ。本ブロックでは onConversion prop を
 *   受け取り、マウント時に一度だけ呼ぶ(実案件ではここで gtag / dataLayer.push を呼ぶ)。
 *   SSRでは実行されないので、タグ側も「クライアントで1回」を前提に設計する。
 * - **営業時間ぶんの加算は「丸め → 消化」の2段**: 受付時刻をまず営業時間内へ丸め(開店前は当日
 *   開店時刻、閉店後・休業日は次の営業日の開店時刻)、その後は残り分数を1日ずつ消化する。
 *   単純に24時間足す実装は、金曜18時の受付で「土曜18時まで」という守れない約束を表示する。
 * - **約束は絶対時刻で書き、残り時間は装飾に落とす**: 「1営業日以内」は受け手が日付に変換できない。
 *   読み上げ対象のテキストには「9月24日(木) 15:00まで」を置き、30秒ごとに動くカウントダウンは
 *   aria-hidden にする(live region にすると30秒おきに読み上げが割り込む)。
 * - **時刻の計算は render の外でだけ行う**: new Date() を render 本体で呼ぶとSSRとクライアントで
 *   値が変わりハイドレーション不一致になる(lint の純粋性ルールにも掛かる)。初期stateは null に
 *   しておき、useEffect の中で受付時刻・期限・受付番号を確定させる。
 * - **控えのコピーは「何を写したか」を本文に出す**: ボタンのラベルだけを差し替えると、実際に
 *   クリップボードへ入った文字列が確認できない。コピー対象の文字列を画面にそのまま表示し、
 *   ボタンはその文字列を writeText するだけにする(失敗時は選択して手でコピーできる)。
 * - **待機中の導線は3つで打ち止めにし、所要の目安を添える**: 「ついでに見てほしいもの」を
 *   並べるほど回遊は落ちる。件数の上限を設計として決め、各行に読了3分・12問・PDFの容量など
 *   「踏む前に分かる負荷」を置くと、選ばれる率が上がる。
 */

type Props = {
  /** ギャラリー側は "p" を渡して h1 の重複を避ける(実案件では既定の h1 のまま使う) */
  headingTag?: "h1" | "p";
  /**
   * 計測タグの発火口。実案件では gtag("event", "generate_lead", ...) 等をここへ渡す。
   * マウント時にセッション内で1度だけ呼ばれる。
   */
  onConversion?: (payload: { ticket: string; at: string }) => void;
};

/** 営業時間(平日 10:00–19:00)。返信の約束もこの枠の中だけで数える */
const OPEN_HOUR = 10;
const CLOSE_HOUR = 19;
/** 「1営業日以内」=営業時間9時間ぶん */
const REPLY_BUSINESS_MINUTES = (CLOSE_HOUR - OPEN_HOUR) * 60;

/** 休業日(サンプル。実案件では祝日表から生成する) */
const HOLIDAYS = new Set([
  "2026-01-01",
  "2026-01-02",
  "2026-01-03",
  "2026-09-21",
  "2026-09-22",
  "2026-11-03",
  "2026-11-23",
]);

const WEEKDAY_JA = ["日", "月", "火", "水", "木", "金", "土"];

function ymd(d: Date) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
    d.getDate(),
  ).padStart(2, "0")}`;
}

function isBusinessDay(d: Date) {
  const w = d.getDay();
  if (w === 0 || w === 6) return false;
  return !HOLIDAYS.has(ymd(d));
}

/** 営業時間内へ丸める(開店前は当日開店、閉店後・休業日は次の営業日の開店) */
function clampToBusiness(from: Date) {
  const cur = new Date(from);
  for (let guard = 0; guard < 400; guard += 1) {
    if (!isBusinessDay(cur)) {
      cur.setDate(cur.getDate() + 1);
      cur.setHours(OPEN_HOUR, 0, 0, 0);
      continue;
    }
    if (cur.getHours() < OPEN_HOUR) {
      cur.setHours(OPEN_HOUR, 0, 0, 0);
      return cur;
    }
    if (cur.getHours() >= CLOSE_HOUR) {
      cur.setDate(cur.getDate() + 1);
      cur.setHours(OPEN_HOUR, 0, 0, 0);
      continue;
    }
    return cur;
  }
  return cur;
}

/** 営業時間ぶんの分数を加算する(休業日と閉店時間をまたいで消化する) */
function addBusinessMinutes(from: Date, minutes: number) {
  let cur = clampToBusiness(from);
  let left = minutes;
  for (let guard = 0; guard < 400 && left > 0; guard += 1) {
    const close = new Date(cur);
    close.setHours(CLOSE_HOUR, 0, 0, 0);
    const available = (close.getTime() - cur.getTime()) / 60000;
    if (available >= left) {
      cur = new Date(cur.getTime() + left * 60000);
      left = 0;
    } else {
      left -= available;
      cur = clampToBusiness(new Date(close.getTime() + 60000));
    }
  }
  return cur;
}

function formatStamp(d: Date) {
  return `${d.getMonth() + 1}月${d.getDate()}日(${WEEKDAY_JA[d.getDay()]}) ${String(
    d.getHours(),
  ).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}

/** 受付番号: 日付 + セッション内で一意な4桁。控えとして口頭でも読み上げられる長さにする */
function makeTicket(d: Date) {
  const seq = Math.floor(Math.random() * 9000) + 1000;
  return `CT-${ymd(d).replace(/-/g, "")}-${seq}`;
}

type Receipt = {
  received: Date;
  deadline: Date;
  ticket: string;
};

/** 待っている間に見てほしいもの。3件で打ち止めにする(増やすほど選ばれない) */
const WAITING: { title: string; note: string; load: string }[] = [
  {
    title: "制作実績を見る",
    note: "業種・規模・ご予算帯から近い事例を探せます。公開許可をいただいた案件のみ掲載しています。",
    load: "読了 3分",
  },
  {
    title: "よくあるご質問",
    note: "費用の考え方、着手から公開までの期間、公開後の更新作業について。ご相談前の確認にどうぞ。",
    load: "全 12問",
  },
  {
    title: "進め方の資料をダウンロード",
    note: "お見積りの内訳、ご用意いただく素材、当社と御社の分担を1枚にまとめた資料です。",
    load: "PDF 1.2MB",
  },
];

/** 下線ワイプのテキストリンク(末尾の矢印は置かない) */
function WipeLink({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <a
      href="#"
      className={`group relative inline-block focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 ${className}`}
    >
      {children}
      <span
        aria-hidden="true"
        className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none"
      />
      <span
        aria-hidden="true"
        className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none"
      />
    </a>
  );
}

export default function ThanksConversionPage({
  headingTag: Heading = "h1",
  onConversion,
}: Props) {
  const [receipt, setReceipt] = useState<Receipt | null>(null);
  const [now, setNow] = useState<Date | null>(null);
  const [copied, setCopied] = useState(false);
  const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  // 時刻に触れるのは render の外だけ(SSRとの不一致と lint の純粋性ルールを避ける)
  // effect の本体で直接 setState するとカスケード再描画になるので、次フレームへ逃がす
  useEffect(() => {
    let timer: ReturnType<typeof setInterval> | undefined;
    const raf = requestAnimationFrame(() => {
      const received = new Date();
      setReceipt({
        received,
        deadline: addBusinessMinutes(received, REPLY_BUSINESS_MINUTES),
        ticket: makeTicket(received),
      });
      setNow(received);
      // カウントダウンは分表示なので30秒で足りる(rAFで回し続ける理由が無い)
      timer = setInterval(() => setNow(new Date()), 30000);
    });
    return () => {
      cancelAnimationFrame(raf);
      if (timer) clearInterval(timer);
    };
  }, []);

  // 計測タグはセッション内で1度だけ。戻る・リロードでの二重計上を防ぐ
  useEffect(() => {
    if (!receipt || !onConversion) return;
    const key = "fc-thanks-conversion-fired";
    try {
      if (window.sessionStorage.getItem(key)) return;
      window.sessionStorage.setItem(key, "1");
    } catch {
      // プライベートブラウジング等で使えないときは素通りさせる(計測のために本体を止めない)
    }
    onConversion({ ticket: receipt.ticket, at: receipt.received.toISOString() });
  }, [receipt, onConversion]);

  useEffect(
    () => () => {
      if (copyTimer.current) clearTimeout(copyTimer.current);
    },
    [],
  );

  const handleCopy = useCallback(async () => {
    if (!receipt) return;
    try {
      await navigator.clipboard.writeText(receipt.ticket);
      setCopied(true);
      if (copyTimer.current) clearTimeout(copyTimer.current);
      copyTimer.current = setTimeout(() => setCopied(false), 2000);
    } catch {
      // 権限が無い環境では画面の文字列を手で選択してもらう(ボタンは黙って何もしない)
    }
  }, [receipt]);

  // 残り時間と経過率。どちらも装飾扱いで、約束の本体は絶対時刻のテキスト側に置く
  let remainText = "算出中";
  let elapsedRatio = 0;
  if (receipt && now) {
    const total = receipt.deadline.getTime() - receipt.received.getTime();
    const left = receipt.deadline.getTime() - now.getTime();
    if (left <= 0) {
      remainText = "まもなくご返信します";
    } else {
      const h = Math.floor(left / 3600000);
      const m = Math.floor((left % 3600000) / 60000);
      remainText = h > 0 ? `あと約 ${h}時間${m}分` : `あと約 ${m}分`;
    }
    elapsedRatio =
      total > 0 ? Math.min(1, Math.max(0, (now.getTime() - receipt.received.getTime()) / total)) : 0;
  }

  const num = `${montserrat.className} tabular-nums`;

  return (
    <article
      aria-labelledby="thanks-heading"
      className="w-full max-w-2xl bg-white px-1 py-10 sm:px-2 sm:py-12"
    >
      {/* 完了の事実は小さく1行。画面の主役は下の返信期限に譲る */}
      <p className="flex items-center gap-2 text-[11px] leading-none font-bold tracking-[0.12em] text-gray-900">
        <span aria-hidden="true" className="inline-block size-1.5 shrink-0 bg-amber-600" />
        送信完了
      </p>

      <Heading
        id="thanks-heading"
        className="mt-4 text-[clamp(1.45rem,4.4vw,2rem)] leading-[1.5] font-bold tracking-tight text-gray-900"
      >
        お問い合わせを受け付けました
      </Heading>

      <p className="mt-4 text-[13px] leading-[1.9] text-gray-600">
        {/* 和文の段落は1行で書く(JSXは改行を半角スペースへ畳むため、行を割ると句点の後だけ空く) */}
        内容は担当者が直接読みます。自動返信ではなく、人が書いた返信をお送りします。折り返しの期限は下記のとおりです。
      </p>

      {/* ── 最上部の主役: いつまでに返信するか ─────────────────────── */}
      <section
        aria-labelledby="thanks-deadline-heading"
        className="mt-8 border-t-2 border-gray-900 pt-6"
      >
        <h2
          id="thanks-deadline-heading"
          className="text-[11px] leading-none font-bold tracking-[0.12em] text-gray-500"
        >
          ご返信の期限
        </h2>

        {/* 約束の本体は絶対時刻。読み上げ対象はこの1行で完結させる */}
        <p className="mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1">
          <span
            className={`${num} text-[clamp(1.6rem,6vw,2.4rem)] leading-[1.25] font-bold tracking-tight text-gray-900`}
          >
            {receipt ? formatStamp(receipt.deadline) : "--月--日(-) --:--"}
          </span>
          <span className="text-[15px] leading-[1.6] font-bold text-gray-900">まで</span>
        </p>

        {/* 30秒ごとに動く表示は live region にしない(読み上げが割り込むため) */}
        <div aria-hidden="true" className="mt-4">
          <div className="flex items-baseline justify-between gap-3">
            <span className={`${num} text-[13px] font-semibold text-amber-700`}>{remainText}</span>
            <span className="text-[11px] leading-none text-gray-400">受付から返信期限まで</span>
          </div>
          <div className="mt-2 h-px w-full bg-gray-200">
            <span
              className="block h-px origin-left bg-amber-600 transition-transform duration-1000 ease-out motion-reduce:transition-none"
              style={{ transform: `scaleX(${elapsedRatio})` }}
            />
          </div>
        </div>

        <dl className="mt-5 grid grid-cols-[5.5rem_1fr] gap-x-3 gap-y-2 border-t border-gray-100 pt-4 sm:grid-cols-[6.5rem_1fr]">
          <dt className="pt-[0.2em] text-[11px] leading-[1.7] font-bold tracking-[0.1em] text-gray-500">
            受付日時
          </dt>
          <dd className={`${num} text-[13px] leading-[1.8] font-medium text-gray-900`}>
            {receipt ? formatStamp(receipt.received) : "--月--日(-) --:--"}
          </dd>
          <dt className="pt-[0.2em] text-[11px] leading-[1.7] font-bold tracking-[0.1em] text-gray-500">
            営業時間
          </dt>
          <dd className="text-[13px] leading-[1.8] font-medium text-gray-900">
            平日 <span className={num}>10:00–19:00</span>
          </dd>
          <dt className="pt-[0.2em] text-[11px] leading-[1.7] font-bold tracking-[0.1em] text-gray-500">
            休業日
          </dt>
          <dd className="text-[13px] leading-[1.8] font-medium text-gray-900">
            土日祝。受付が時間外の場合は次の営業日から数えます。
          </dd>
        </dl>
      </section>

      {/* ── 控え ───────────────────────────────────── */}
      <section
        aria-labelledby="thanks-receipt-heading"
        className="mt-10 border-t border-gray-200 pt-6"
      >
        <h2
          id="thanks-receipt-heading"
          className="text-[11px] leading-none font-bold tracking-[0.12em] text-gray-500"
        >
          受付の控え
        </h2>

        <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
          {/* コピー対象の文字列をそのまま画面に出す(何が写ったかを目で確かめられる) */}
          <span
            className={`${num} border border-gray-300 px-2.5 py-1.5 text-[15px] leading-none font-semibold text-gray-900 select-all`}
          >
            {receipt ? receipt.ticket : "CT-————————-————"}
          </span>
          <button
            type="button"
            onClick={handleCopy}
            disabled={!receipt}
            className="text-[12px] font-semibold text-gray-900 underline underline-offset-4 transition-opacity duration-200 hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 disabled:opacity-40 motion-reduce:transition-none"
          >
            {copied ? "コピーしました" : "受付番号をコピー"}
          </button>
        </div>

        <p className="mt-3 text-[12px] leading-[1.9] text-gray-500">
          お電話でのご確認や、追加のご連絡をいただく際にこの番号をお伝えください。この画面は控えとしてそのまま閉じていただいて構いません。
        </p>

        <dl className="mt-5 divide-y divide-gray-100 border-t border-gray-100">
          <div className="grid grid-cols-[5.5rem_1fr] gap-x-3 py-2.5 sm:grid-cols-[6.5rem_1fr]">
            <dt className="pt-[0.2em] text-[11px] leading-[1.7] font-bold tracking-[0.1em] text-gray-500">
              返信先
            </dt>
            <dd className="text-[13px] leading-[1.8] font-medium break-all text-gray-900">
              you@example.com
            </dd>
          </div>
          <div className="grid grid-cols-[5.5rem_1fr] gap-x-3 py-2.5 sm:grid-cols-[6.5rem_1fr]">
            <dt className="pt-[0.2em] text-[11px] leading-[1.7] font-bold tracking-[0.1em] text-gray-500">
              ご用件
            </dt>
            <dd className="text-[13px] leading-[1.8] font-medium text-gray-900">
              サイトの新規制作について
            </dd>
          </div>
        </dl>
      </section>

      {/* ── 届かないときは ────────────────────────────── */}
      <section
        aria-labelledby="thanks-fallback-heading"
        className="mt-10 border-l-2 border-amber-600 bg-amber-50/60 px-4 py-4 sm:px-5"
      >
        <h2
          id="thanks-fallback-heading"
          className="text-[13px] leading-[1.6] font-bold text-gray-900"
        >
          期限までに返信が届かないとき
        </h2>
        <p className="mt-2 text-[12px] leading-[1.9] text-gray-700">
          迷惑メールフォルダに振り分けられている場合があります。見当たらないときは、お手数ですがお電話ください。受付番号をお伝えいただければ、その場で状況をお調べします。
        </p>
        <p className="mt-3 text-[13px] leading-[1.8] font-bold text-gray-900">
          <span className={num}>050-0000-0000</span>
          <span className="ml-2 text-[11px] font-medium text-gray-500">
            平日 <span className={num}>10:00–19:00</span>
          </span>
        </p>
      </section>

      {/* ── お待ちいただく間に(3件で打ち止め) ──────────────────── */}
      <section
        aria-labelledby="thanks-waiting-heading"
        className="mt-10 border-t border-gray-200 pt-6"
      >
        <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
          <h2
            id="thanks-waiting-heading"
            className="text-[11px] leading-none font-bold tracking-[0.12em] text-gray-500"
          >
            お待ちいただく間に
          </h2>
          <span className="text-[11px] leading-none text-gray-400">
            ご案内は <span className={num}>3</span> 件までにしています
          </span>
        </div>

        <ul className="mt-4 divide-y divide-gray-100 border-t border-gray-100">
          {WAITING.map((item, i) => (
            <li key={item.title} className="grid grid-cols-[1.75rem_1fr] gap-x-2 py-4">
              <span
                aria-hidden="true"
                className={`${num} pt-[0.25em] text-[11px] font-semibold text-gray-400`}
              >
                {String(i + 1).padStart(2, "0")}
              </span>
              <div>
                <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
                  <WipeLink className="text-[14px] leading-[1.6] font-bold text-gray-900">
                    {item.title}
                  </WipeLink>
                  {/* 踏む前に負荷が分かるよう、所要の目安を行内に置く */}
                  <span
                    className={`${num} shrink-0 border border-gray-200 px-1.5 py-px text-[10px] leading-[1.7] font-semibold text-gray-500`}
                  >
                    {item.load}
                  </span>
                </div>
                <p className="mt-1.5 text-[12px] leading-[1.9] text-gray-600">{item.note}</p>
              </div>
            </li>
          ))}
        </ul>
      </section>

      <p className="mt-8 text-[12px] leading-[1.9] text-gray-500">
        <WipeLink className="font-semibold text-gray-900">トップページへ戻る</WipeLink>
      </p>
    </article>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/thanks-conversion-page.json