First CH Designs
BLK-49Blocks

セミナー・イベント一覧(開催日ブロック型)

左端に月/日を特大のタブラー数字で積んだ日付ブロックを起点にする、セミナー・イベントの開催日一覧。1行が席という在庫を持ち、残席の状態(受付中/残りわずか/満席/受付終了)が席数ぶんの目盛りゲージ・申込締切・操作の可否をまとめて切り替える。満席回と終了回はカードではなく傾いた判子で示し、終了した回はスイッチで開閉する。角丸・英字キッカー・矢印リンクを使わず、hairline罫線と余白だけで月ごとに段を作る。画像素材ゼロ・依存ゼロ。

追加日:
2026-09-02
依存:
なし
tags
#block #event #seminar #schedule #list #date #availability #no-image

プレビュー

開催予定のセミナー

制作の現場で実際に聞かれることを題材にした少人数の勉強会です。会場開催は席数が限られるため、残席と締切をご確認のうえお申し込みください。掲載内容はすべて架空のサンプルです。

BLK-49

4回 受付中

受付中の4件を表示しています

0920262

  • オンライン19:0020:30オンライン開催(ウェビナー)

    問い合わせが止まるサイトの直し方

    アクセスはあるのに反応がない。原因を導線・文言・フォームの3層に分けて切り分ける手順を、実際の改修前後の画面で追います。

    受付中残り12席 /40

    申込締切 9月9日

  • 会場14:0016:30本社セミナールーム(定員24名)

    自分たちで更新できるサイトの決め方

    公開後にどこを触れるようにするかを、要件定義の段階で線引きするためのワークショップ。持ち帰り用のチェックシート付き。

    残りわずか残り3席 /24

    申込締切 9月19日

1020262

  • オンライン19:0020:30オンライン開催(ウェビナー)

    見積書の行を読み解く90分

    制作会社の見積が比べられないのは、粒度が揃っていないからです。行の意味と、聞き返すべき項目を具体的に整理します。

    満席

    申込締切 10月6日

  • 会場13:3017:00本社セミナールーム(定員18名)

    撮影のない会社案内ページをつくる半日

    写真素材が用意できない状態から、文字組みと余白だけで紙面を立てる進め方。その場で1ページ分を組み上げます。

    受付中残り9席 /18

    申込締切 10月16日

日程が合わない場合は、次回開催のご案内をお送りします。過去の開催内容は上のスイッチでご確認いただけます。

"use client";

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

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

/**
 * セミナー・イベント一覧(開催日ブロック型) / Seminar Schedule — Date-Block List
 *
 * 差別化: 同じ棚の既存一覧(罫線ニュースリスト・主従+タブ・読了時間ソート)はどれも
 * 「全行が等価に開ける読み物」で、行の違いは分類か長さだけだった。本ブロックは1行が席という
 * 在庫を持ち、残席の状態(受付中/残りわずか/満席/受付終了)が残席ゲージ・締切表示・
 * 操作の可否そのものを切り替える「申込台帳」として組んでいる。日付は識別子ではなく予定を
 * 押さえる対象なので、左端に月/日を特大のタブラー数字で積んで行の起点にした。
 * 掲載している日付・題目・講師名はすべて架空のオリジナル。画像素材ゼロ・依存ゼロ。
 *
 * 飽和idiomを3つ外している: 英字キッカー(uppercase+tracking)/角丸(rounded-* を一切使わない・
 * 判子と席目盛りの直角が効く)/末尾の「→」リンク(行のCTAは動詞のボタンで、矢印を持たない)。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - **在庫を持つ一覧は「状態」を1つの型にまとめる**: 受付中/残りわずか/満席/終了を union で持ち、
 *   ラベル・色・操作可否・判子の有無をその1箇所から導く。行のJSXに条件分岐を散らさないので、
 *   状態が増えても崩れない(見た目の差はテーブル1枚で説明できる状態にしておく)。
 * - **残席ゲージは目盛りの本数で見せる**: 割合バー1本ではなく w-[3px] の縦目盛りを席数分だけ並べ、
 *   埋まった分を amber、空きを gray-200 にする。「あと3本」が数えられるので、%より切迫が伝わる。
 *   出現時は origin-bottom の scaleY(0)→1 を index*28ms でずらす(高さは変えずリフローゼロ)。
 * - **判子(スタンプ)は rotate+scale を inline style の transform で1つにまとめる**: Tailwind v4 の
 *   rotate/scale ユーティリティは transform ではなく個別プロパティ(rotate:/scale:)に出るので、
 *   自前の transform を持つ要素に足しても合成されず、狙いの姿勢にならない。押印の
 *   「大きく降りてくる」演出は transform: rotate(-8deg) scale(1.6) → scale(1) の1本で書く。
 *   (transition-transform 自体は v4 では transform/translate/scale/rotate すべてを対象にする)
 * - **終了回は非表示が既定・トグルで戻す**: 絞り込み(カテゴリ)と違い時間軸の開閉なので、
 *   role="switch" + aria-checked で「入/切」として持たせ、件数変化は sr-only の aria-live で伝える。
 * - **日付ブロックはカードにしない**: 面(bg/枠)を与えず、左端の w-px hairline と amber の
 *   3px マーカーだけで列を作る。行の状態が変わってもマーカーの色が変わるだけで面が動かない。
 */

/** 席の在庫状態。ラベル・色・操作・判子はすべてここから導く */
type Status = "open" | "few" | "full" | "closed";

type Session = {
  id: string;
  /** 開催日(time要素の datetime にもそのまま使う) */
  date: string;
  /** 表示用の曜日(datetime は date 側が持つので装飾扱い) */
  weekday: string;
  start: string;
  end: string;
  format: "会場" | "オンライン";
  place: string;
  title: string;
  lead: string;
  /** 定員と残席。ゲージの目盛り本数は capacity をそのまま使う */
  capacity: number;
  left: number;
  /** 申込締切(受付終了回は表示しない) */
  deadline?: string;
};

const STATUS_STYLE: Record<
  Status,
  { label: string; text: string; marker: string; action: string | null }
> = {
  open: { label: "受付中", text: "text-amber-700", marker: "bg-amber-600", action: "申し込む" },
  few: { label: "残りわずか", text: "text-amber-700", marker: "bg-amber-600", action: "申し込む" },
  full: { label: "満席", text: "text-gray-500", marker: "bg-gray-400", action: "キャンセル待ち" },
  closed: { label: "受付終了", text: "text-gray-400", marker: "bg-gray-200", action: null },
};

function statusOf(s: Session, ended: boolean): Status {
  if (ended) return "closed";
  if (s.left === 0) return "full";
  return s.left <= 3 ? "few" : "open";
}

const upcoming: Session[] = [
  {
    id: "s-09-11",
    date: "2026-09-11",
    weekday: "金",
    start: "19:00",
    end: "20:30",
    format: "オンライン",
    place: "オンライン開催(ウェビナー)",
    title: "問い合わせが止まるサイトの直し方",
    lead: "アクセスはあるのに反応がない。原因を導線・文言・フォームの3層に分けて切り分ける手順を、実際の改修前後の画面で追います。",
    capacity: 40,
    left: 12,
    deadline: "9月9日",
  },
  {
    id: "s-09-24",
    date: "2026-09-24",
    weekday: "木",
    start: "14:00",
    end: "16:30",
    format: "会場",
    place: "本社セミナールーム(定員24名)",
    title: "自分たちで更新できるサイトの決め方",
    lead: "公開後にどこを触れるようにするかを、要件定義の段階で線引きするためのワークショップ。持ち帰り用のチェックシート付き。",
    capacity: 24,
    left: 3,
    deadline: "9月19日",
  },
  {
    id: "s-10-08",
    date: "2026-10-08",
    weekday: "木",
    start: "19:00",
    end: "20:30",
    format: "オンライン",
    place: "オンライン開催(ウェビナー)",
    title: "見積書の行を読み解く90分",
    lead: "制作会社の見積が比べられないのは、粒度が揃っていないからです。行の意味と、聞き返すべき項目を具体的に整理します。",
    capacity: 40,
    left: 0,
    deadline: "10月6日",
  },
  {
    id: "s-10-22",
    date: "2026-10-22",
    weekday: "木",
    start: "13:30",
    end: "17:00",
    format: "会場",
    place: "本社セミナールーム(定員18名)",
    title: "撮影のない会社案内ページをつくる半日",
    lead: "写真素材が用意できない状態から、文字組みと余白だけで紙面を立てる進め方。その場で1ページ分を組み上げます。",
    capacity: 18,
    left: 9,
    deadline: "10月16日",
  },
];

const past: Session[] = [
  {
    id: "s-08-21",
    date: "2026-08-21",
    weekday: "金",
    start: "19:00",
    end: "20:30",
    format: "オンライン",
    place: "オンライン開催(ウェビナー)",
    title: "運用でサイトが散らかっていく理由",
    lead: "更新を重ねるほど読みにくくなる原因を、ページの増やし方と見出しの付け方から点検しました。",
    capacity: 40,
    left: 0,
  },
  {
    id: "s-07-30",
    date: "2026-07-30",
    weekday: "木",
    start: "14:00",
    end: "16:00",
    format: "会場",
    place: "本社セミナールーム(定員24名)",
    title: "はじめての採用サイト設計",
    lead: "求人媒体との役割分担を決めてから、載せる情報の順番を組み立てるまでを扱いました。",
    capacity: 24,
    left: 6,
  },
];

/** 月ごとにまとめる(順序は配列のまま=時系列) */
function groupByMonth(list: Session[]) {
  const groups: { key: string; year: string; month: string; items: Session[] }[] = [];
  for (const s of list) {
    const [year, month] = s.date.split("-");
    const key = `${year}-${month}`;
    const last = groups.at(-1);
    if (last?.key === key) last.items.push(s);
    else groups.push({ key, year, month, items: [s] });
  }
  return groups;
}

export default function SeminarScheduleList() {
  const rootRef = useRef<HTMLElement | null>(null);
  const [active, setActive] = useState(false);
  const [showPast, setShowPast] = useState(false);
  const switchId = useId();

  // セクションが視野に入ったら1度だけ全体を立ち上げる(行ごとの遅延はCSS側で付ける)
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const reduced =
      typeof window !== "undefined" &&
      window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
    if (reduced || typeof IntersectionObserver === "undefined") {
      // 「止める」のではなく最初から完成状態にする(setStateは次フレームへ逃がす)
      const id = requestAnimationFrame(() => setActive(true));
      return () => cancelAnimationFrame(id);
    }
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setActive(true);
          io.disconnect();
        }
      },
      { threshold: 0.1 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const groups = useMemo(
    () => [
      ...groupByMonth(upcoming).map((g) => ({ ...g, ended: false })),
      ...(showPast ? [{ key: "past", year: "", month: "", items: past, ended: true }] : []),
    ],
    [showPast],
  );

  const reveal = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
    active
      ? "translate-y-0 opacity-100"
      : "translate-y-4 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
  }`;

  // 行の通し番号(グループをまたいでスタッガーを連続させる)
  let rowIndex = -1;

  return (
    <section
      ref={rootRef}
      aria-labelledby="seminar-schedule-heading"
      className="w-full max-w-5xl bg-white px-1"
    >
      <header className="flex flex-col gap-x-10 gap-y-5 border-b border-gray-200 pb-6 md:flex-row md:items-end md:justify-between">
        <div>
          <h2
            id="seminar-schedule-heading"
            className={`text-[clamp(1.55rem,3.2vw,2.15rem)] leading-[1.35] font-bold tracking-tight text-gray-900 ${reveal}`}
          >
            開催予定のセミナー
          </h2>
          <p
            className={`mt-3 max-w-xl text-[13.5px] leading-[1.95] font-medium text-gray-600 ${reveal}`}
            style={{ transitionDelay: "90ms" }}
          >
            {/* 和文は改行で分けると「、 残」のように空きが出るため1行で書く */}
            制作の現場で実際に聞かれることを題材にした少人数の勉強会です。会場開催は席数が限られるため、残席と締切をご確認のうえお申し込みください。掲載内容はすべて架空のサンプルです。
          </p>
        </div>
        <div className={`shrink-0 md:text-right ${reveal}`} style={{ transitionDelay: "160ms" }}>
          <p className={`${montserrat.className} text-[11px] font-semibold text-gray-400`}>
            BLK-49
          </p>
          <p className="mt-1 text-[12.5px] font-semibold text-gray-500">
            全
            <span className={`${montserrat.className} mx-0.5 text-gray-900 tabular-nums`}>
              {upcoming.length}
            </span>
            回 受付中
          </p>
        </div>
      </header>

      {/* 終了した回の開閉。絞り込みではなく時間軸の入/切なので switch で持つ */}
      <div
        className={`mt-6 flex items-center justify-between gap-4 ${reveal}`}
        style={{ transitionDelay: "220ms" }}
      >
        <label
          htmlFor={switchId}
          className="text-[12.5px] font-semibold tracking-[0.04em] text-gray-600"
        >
          終了した回も表示
        </label>
        <button
          type="button"
          id={switchId}
          role="switch"
          aria-checked={showPast}
          onClick={() => setShowPast((v) => !v)}
          className={`relative inline-flex h-5 w-11 shrink-0 items-center border transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none ${
            showPast ? "border-amber-600 bg-amber-50" : "border-gray-300 bg-white"
          }`}
        >
          <span
            aria-hidden="true"
            className={`ml-px h-[15px] w-[19px] transition-transform duration-200 ease-out motion-reduce:transition-none ${
              showPast ? "translate-x-[21px] bg-amber-600" : "translate-x-0 bg-gray-300"
            }`}
          />
        </button>
      </div>

      <p aria-live="polite" className="sr-only">
        {showPast
          ? `終了した回を含む全${upcoming.length + past.length}件を表示しています`
          : `受付中の${upcoming.length}件を表示しています`}
      </p>

      <div className="mt-2">
        {groups.map((group, gi) => (
          <section key={group.key} aria-label={group.ended ? "終了した回" : `${group.month}月の開催`}>
            {/* 月見出し: 面を持たず、hairline と余白だけで段を作る */}
            <h3 className="flex items-baseline gap-3 border-b border-gray-200 pt-8 pb-2">
              {group.ended ? (
                <span className="text-[13px] font-bold tracking-[0.06em] text-gray-400">
                  終了した回
                </span>
              ) : (
                <>
                  <span
                    className={`${montserrat.className} text-[22px] leading-none font-bold text-gray-900 tabular-nums`}
                  >
                    {group.month}
                  </span>
                  <span className="text-[12.5px] font-bold text-gray-900">月</span>
                  <span
                    className={`${montserrat.className} text-[11px] font-semibold text-gray-400 tabular-nums`}
                  >
                    {group.year}
                  </span>
                </>
              )}
              <span
                className={`${montserrat.className} ml-auto text-[11px] font-semibold text-gray-400 tabular-nums`}
              >
                {group.items.length}
                <span className="ml-0.5 font-sans text-gray-400">件</span>
              </span>
            </h3>

            <ul>
              {group.items.map((s) => {
                rowIndex += 1;
                const status = statusOf(s, group.ended);
                const style = STATUS_STYLE[status];
                const dimmed = status === "closed";
                const [, month, day] = s.date.split("-");
                const filled = s.capacity - s.left;
                const delay = 300 + rowIndex * 90;

                return (
                  <li
                    key={s.id}
                    className={`border-b border-gray-200 ${reveal}`}
                    style={{ transitionDelay: active ? `${delay}ms` : "0ms" }}
                  >
                    <div className="grid grid-cols-[auto_1fr] gap-x-4 py-6 sm:gap-x-7 md:grid-cols-[auto_1fr_auto] md:gap-x-8">
                      {/* 日付ブロック: 月/日/曜日を積む。カードにせず左端のマーカーだけで列を作る */}
                      <div className="relative flex flex-col items-center pl-3 sm:pl-4">
                        <span
                          aria-hidden="true"
                          className={`absolute top-1 bottom-1 left-0 w-[3px] ${style.marker}`}
                        />
                        <time
                          dateTime={s.date}
                          className={`flex flex-col items-center ${dimmed ? "text-gray-400" : "text-gray-900"}`}
                        >
                          <span
                            className={`${montserrat.className} text-[13px] leading-none font-semibold tabular-nums ${
                              dimmed ? "text-gray-400" : "text-amber-700"
                            }`}
                          >
                            {month}
                            <span className="ml-px font-sans text-[10px]">月</span>
                          </span>
                          <span
                            className={`${montserrat.className} mt-1 text-[clamp(2.4rem,7vw,3.1rem)] leading-[0.86] font-bold tracking-tight tabular-nums`}
                          >
                            {day}
                          </span>
                        </time>
                        <span
                          className={`mt-1.5 text-[11px] font-semibold ${dimmed ? "text-gray-300" : "text-gray-500"}`}
                        >
                          {s.weekday}曜
                        </span>
                      </div>

                      {/* 本文 */}
                      <div className="min-w-0">
                        <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
                          <span
                            className={`inline-flex h-[22px] items-center border px-2 text-[11px] font-bold tracking-[0.04em] ${
                              dimmed
                                ? "border-gray-200 text-gray-400"
                                : s.format === "オンライン"
                                  ? "border-amber-600 text-amber-700"
                                  : "border-gray-900 text-gray-900"
                            }`}
                          >
                            {s.format}
                          </span>
                          <span
                            className={`${montserrat.className} text-[12.5px] font-semibold tabular-nums ${
                              dimmed ? "text-gray-400" : "text-gray-700"
                            }`}
                          >
                            {s.start}–{s.end}
                          </span>
                          <span
                            aria-hidden="true"
                            className="hidden h-3 w-px bg-gray-200 sm:block"
                          />
                          <span
                            className={`text-[12px] font-medium ${dimmed ? "text-gray-400" : "text-gray-500"}`}
                          >
                            {s.place}
                          </span>
                        </div>

                        <h4
                          className={`mt-2.5 text-[clamp(1rem,1.9vw,1.2rem)] leading-[1.5] font-bold tracking-tight ${
                            dimmed ? "text-gray-400" : "text-gray-900"
                          }`}
                        >
                          {s.title}
                        </h4>
                        <p
                          className={`mt-2 max-w-2xl text-[13px] leading-[1.9] font-medium ${
                            dimmed ? "text-gray-400" : "text-gray-600"
                          }`}
                        >
                          {s.lead}
                        </p>

                        {/* 残席ゲージ: 席数ぶんの目盛りを並べ、埋まった分だけ塗る */}
                        <div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
                          {/* 目盛りの間隔は狭幅で詰める(40席でも1行に収める) */}
                          <div className="flex items-end gap-px sm:gap-[2px]" aria-hidden="true">
                            {Array.from({ length: s.capacity }, (_, i) => (
                              <span
                                key={i}
                                className={`h-3 w-[2px] origin-bottom transition-transform sm:w-[3px] duration-500 ease-out motion-reduce:transition-none ${
                                  dimmed
                                    ? "bg-gray-200"
                                    : i < filled
                                      ? "bg-amber-600"
                                      : "bg-gray-200"
                                }`}
                                style={{
                                  // 目盛りごとに遅延を変えるので transform も style 側にまとめる
                                  // (Tailwind の scale-* は scale プロパティに出るため自前の transform と混ぜない)
                                  transform: `scaleY(${active ? 1 : 0})`,
                                  transitionDelay: active ? `${delay + 120 + i * 28}ms` : "0ms",
                                }}
                              />
                            ))}
                          </div>
                          <p
                            className={`text-[12px] font-bold tracking-[0.04em] ${style.text}`}
                          >
                            {style.label}
                            {!dimmed && s.left > 0 && (
                              <span className="ml-2 font-semibold text-gray-500">
                                残り
                                <span
                                  className={`${montserrat.className} mx-0.5 text-gray-900 tabular-nums`}
                                >
                                  {s.left}
                                </span>
                                席 /
                                <span
                                  className={`${montserrat.className} mx-0.5 tabular-nums`}
                                >
                                  {s.capacity}
                                </span>
                                席
                              </span>
                            )}
                          </p>
                          {s.deadline && !dimmed && (
                            <p className="text-[12px] font-semibold text-gray-500">
                              申込締切 <span className="text-gray-900">{s.deadline}</span>
                            </p>
                          )}
                        </div>
                      </div>

                      {/* 操作列 / 判子。満席・終了は押せる先がないので判子に置き換える */}
                      <div className="col-span-2 mt-4 flex items-center gap-4 md:col-span-1 md:mt-0 md:w-[148px] md:flex-col md:items-end md:justify-center md:gap-3">
                        {dimmed || status === "full" ? (
                          <span
                            className={`inline-flex h-[54px] w-[76px] flex-col items-center justify-center border-2 text-[13px] leading-none font-bold tracking-[0.08em] transition-[opacity,transform] duration-500 ease-out motion-reduce:transition-none ${
                              status === "full"
                                ? "border-amber-600/70 text-amber-700"
                                : "border-gray-300 text-gray-400"
                            } ${active ? "opacity-100" : "opacity-0 motion-reduce:opacity-100"}`}
                            style={{
                              transform: `rotate(-8deg) scale(${active ? 1 : 1.6})`,
                              transitionDelay: active ? `${delay + 260}ms` : "0ms",
                            }}
                          >
                            <span className="border-b border-current pb-1">
                              {status === "full" ? "満席" : "終了"}
                            </span>
                            <span
                              className={`${montserrat.className} mt-1 text-[9px] font-semibold tracking-[0.12em] tabular-nums`}
                            >
                              {s.date.replace(/-/g, ".")}
                            </span>
                          </span>
                        ) : null}

                        {style.action && !dimmed && (
                          <a
                            href="#"
                            className={`inline-flex h-10 items-center justify-center px-5 text-[13px] font-bold tracking-[0.04em] whitespace-nowrap transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none md:w-full ${
                              status === "full"
                                ? "border border-gray-300 text-gray-700 hover:border-gray-900 hover:text-gray-900"
                                : "bg-amber-600 text-white hover:bg-amber-700"
                            }`}
                          >
                            {style.action}
                            <span className="sr-only">
                              ({s.date} {s.title})
                            </span>
                          </a>
                        )}
                      </div>
                    </div>
                  </li>
                );
              })}
            </ul>

            {gi === groups.length - 1 && !showPast && (
              <p className="pt-5 text-[12.5px] leading-[1.9] font-medium text-gray-500">
                日程が合わない場合は、次回開催のご案内をお送りします。過去の開催内容は上のスイッチでご確認いただけます。
              </p>
            )}
          </section>
        ))}
      </div>
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/seminar-schedule-list.json