"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>
);
}