diff --git a/docs/LP-BUILDER.md b/docs/LP-BUILDER.md deleted file mode 100644 index e3352ff..0000000 --- a/docs/LP-BUILDER.md +++ /dev/null @@ -1,221 +0,0 @@ -# ミセテLP — 運用ドキュメント - -「質問に答えるだけで、本番品質の日本語LPが完成し、1枚のHTMLとして持ち帰れる」 -業種特化LPビルダー(`/lp`)の収益設計・公開手順・既知の限界をまとめる。 - -``` -┌──────────────────────────────────────────────┐ -│ /lp ミセテLP ウィザード │ -│ ① 業種選択 → ② 質問フォーム → ③ プレビュー │ -│ → ④ 書き出し(HTML)/ 共有URL / プロジェクト保存 │ -└──────────────────────────────────────────────┘ -``` - -対象: 旅館・民宿 / サロン / クリニック / レストラン・カフェ の店主、および制作会社。 -差別化: 880コンポーネント由来の設計品質そのものが商品(「空白だから勝てる」は使わない)。 - -### できること(機能一覧) - -| 機能 | 概要 | -| --- | --- | -| 業種テンプレート4種 | 旅館(和風)/ サロン(ボタニカル)/ クリニック(ミニマル)/ レストラン・カフェ(洋風)。各6セクション | -| 写真の掲載 | 最大3枚。取り込み時に長辺1600pxへ縮小しJPEG圧縮(`src/lp/photo.ts`)、data URI として書き出しHTMLへ直接埋め込む。外部サーバー不要 | -| お客様の声 | テンプレごとの掲載可能件数(`testimonialSlots`)ぶんを入力し、実際のデモの引用箇所へ差し込む | -| セクションのON/OFF | 骨格(ナビ・ヒーロー・フッター)以外のセクションと写真セクションを表示/非表示にできる | -| 構造化データ | 書き出しHTMLに JSON-LD(schema.org。旅館=Hotel / サロン=BeautySalon / クリニック=MedicalClinic / 飲食=Restaurant)と favicon・theme-color を出力 | -| 自動保存 | 入力を1.5秒デバウンスで `misete:draft` に保存。次回訪問時に「続きから再開」を提示(勝手に復元はしない)。容量超過時は写真を落として本文だけ保存 | -| 共有URL | `#c=` に状態を base64url で載せる。**写真は含めない**(URL長の限界のため) | -| 公開ガイド | 書き出しステップに、無料で公開する手順(Netlify Drop / Cloudflare Pages / レンタルサーバー)を同梱 | - ---- - -## 1. 収益設計(プラン) - -姉妹プロダクト(コンポーネント集・`src/lib/plan.ts`)と同じく **無料 / 買い切りの2段**。 -作るのは1枚のHTMLファイルで、持ち帰った時点で利用者の手元に残る。毎月こちらが何かを -提供し続ける商品ではないため、継続課金は取らない。 - -| プラン | 価格 | 書き出し | フッターバッジ | OGP | 想定ユーザー | -| --- | --- | --- | --- | --- | --- | -| 無料 | ¥0 | 無制限 | 強制表示(`Made with ミセテLP`) | なし | まず試したい店主 | -| フル | ¥9,800(買い切り・税込) | 無制限 | なし | あり | 公開する店主・クライアントに納品する制作者 | - -無料版に書き出し回数の上限は**置かない**(かつての「月3回」は撤廃済み)。理由は -`src/lp/lpPlan.ts` の冒頭コメントに書いてあるとおり: - -1. LPは一度で決まらない。書き出して実機で確認し、直してまた書き出す。回数の上限は - 利用者が価値を感じる前の最初の作業を止めてしまう -2. プラン状態も回数もブラウザの `localStorage` にあり、編集すれば外せる。守れない壁は - 正直な利用者だけを止める -3. **唯一の実効的な壁は、書き出したHTMLに焼き込まれるフッターのバッジ**。無料で公開された - LPがそのまま宣伝になる(Carrd・Tally と同じ形) - -再訪・バイラルの設計(コード変更なしで機能している): -- 複数プロジェクト保存・再編集(`localStorage: misete:projects`)が離脱を防ぐ -- 共有URL(`/lp#c=...`)は **無料・無制限**。バイラル装置として機能させる -- 無料版の書き出しバッジはそれ自体が広告(バッジのリンク先は `SITE_URL/lp`) - -料金・Stripeリンクの定義は `src/lp/lpPlan.ts` が単一の真実。表示文言や価格を -変える場合はここだけ直せば UI 全体(`LpBuilder.tsx` の ExportStep 等)に反映される。 - ---- - -## 2. 公開手順(決済リンク→環境変数→ビルド→Access) - -### 2-1. Stripe Payment Link を発行する - -Stripe ダッシュボード → **Payment Links** → フル版(¥9,800)の**一括払い**リンクを -1本だけ発行する(買い切りなのでサブスクリプションではない)。 - -### 2-2. 環境変数を設定する - -| 変数名 | 値の例 | 用途 | -| --- | --- | --- | -| `VITE_STRIPE_LINK_LP` | `https://buy.stripe.com/xxxxx` | 「フル版を購入」ボタンの遷移先 | -| `VITE_SITE_URL` | `https://misete-lp.pages.dev` | 共有URL・OGP・無料版バッジのリンク先の起点 | - -未設定の場合は `getStripeLink()` が `null` を返し、UI は「デモモード: -プラン体験切替」(`useLpPlan().setPlan` で見た目だけ切り替える)にフォールバックする。 -つまり **環境変数を設定するまでは安全にデモ運用できる**。 - -### 2-3. Cloudflare Pages のビルド環境変数に追加する - -`docs/DEPLOY.md` の手順1でデプロイ済みの Pages プロジェクトに対して: - -**Settings → Environment variables** に上記2つを追加(Production、必要なら Preview にも)→ 再デプロイ。 - -### 2-4. `docs/DEPLOY.md` の Access 設定からの差分:`/lp` だけ公開する - -`docs/DEPLOY.md` は「サイト全体は Private・`/r/*` だけ公開」という2アプリ構成だった。 -ミセテLPは**マーケ用の公開LP**なので、`/lp` にも同様の公開アプリを追加する。 - -1. **Zero Trust → Access → Applications → Add an application → Self-hosted** -2. 設定: - - **Application domain**: `.pages.dev`、**Path**: `lp`(`/lp` と `/lp/*` が対象) - - **Policy**: `Bypass` / Include → **Everyone** -3. 保存 - -Cloudflare Access は最も具体的なパスを優先するため、`/`(スタジオ本体)は -引き続き自分だけの Private のまま、`/lp` と `/r/*` の2つだけが未認証で閲覧できる。 - -``` -┌──────────────────────────────────────────────────┐ -│ https://.pages.dev │ -│ ├─ / スタジオUI → 🔒 Access(自分だけ) │ -│ ├─ /lp ミセテLP → 🌐 公開(Bypass) │ -│ └─ /r/*.json レジストリ配信 → 🌐 公開(Bypass) │ -└──────────────────────────────────────────────────┘ -``` - ---- - -## 3. 既知の限界(正直に) - -現状の実装でできること・できないことを正直に書く。営業・広告文言には転用しないこと。 - -- **プラン状態は `localStorage` のみ**。Stripe Payment Link で決済が完了しても、 - アプリ側の `plan` は自動では `full` にならない。実決済とアプリのエンタイトルメントを - 紐付ける仕組み(Webhook・ユーザーDB)は未実装。現状は Pricing ページと同じ - 「※現在は決済未接続のデモです」という正直な注記つきで、本人が体験用に手動切替する - だけの状態(`Pricing.tsx` の `demoNote` と同じパターン)。 -- **認証なし**。ログイン機構が無いため、自分のブラウザの `localStorage`(`lp:plan`)を - 書き換えれば誰でもバッジなし書き出しを得られる。**この構造上、アプリ内のフラグは - 実効的な壁になりえない — 唯一の実効的な壁は、書き出したHTMLに焼き込まれる - フッターのバッジだけ**(公開されたLPに残り、書き換えるには手でHTMLを編集する必要が - ある。そこまでする人は元から払わない)。だからこそ無料版に回数の上限は置かず、 - バッジ1点に絞っている。不特定多数への商用課金にはまだ耐えない。 -- **ホスト型公開なし**。生成物は1枚の完全自己完結HTML(CSSはビルド時にコンパイル・ - インライン埋め込み済み。外部依存は Google Fonts の読み込みのみで、オフラインでも - フォント以外は崩れない)のダウンロードのみ。ユーザー自身がどこか(自分のサーバー・ - Vercel・GitHub Pages 等)へアップロードする必要があり、「作って終わり」ではなく - 「作って自分で公開する」までが現状のスコープ。 -- **rawSwaps(生HTML置換)は書き出しにのみ効く**。旅館テンプレの暖簾ナビのように、 - 屋号が1文字ずつ独立したdiv要素に分割されているなど `Swap`(テキストノード単位の - 置換)では表現できない箇所は `SectionSlot.rawSwaps`(`src/lp/types.ts`)で書き出し - (`src/lp/export.ts`)時のみHTML断片ごと差し替えている。プレビュー - (`SwapBoundary`)はテキストノード単位でしか置換しないため、rawSwaps は反映されず、 - プレビュー上の縦書き屋号はデモの文言(奥/山/亭)のまま表示される。書き出しHTMLでは - 正しく入力した店名に置き換わる。 -- **「お客様の声」の初期値はサンプル**。入力欄(見出し・本文・お名前・補足)は - 利用者の入力に接続済みだが、初期値には業種ごとのサンプル文が入っている。 - **公開前には必ず実際にいただいた声へ差し替えること**(フォーム上にも同じ注意書きを - 常時表示している)。掲載できる件数はテンプレごとに異なり、現状は4テンプレとも - デモが引用1件構成のため `testimonialSlots: 1`。 -- **写真は書き出しHTMLに直接埋め込まれる**。外部サーバーは不要な反面、写真1枚あたり - 最大で約900KB(data URI 換算)がHTMLに載る。取り込み時に長辺1600pxへ縮小し - JPEG品質を段階的に下げて上限に収めている(`src/lp/photo.ts`)。 - なお `og:image` と JSON-LD の `image` には**意図的に写真を入れていない** — - SNS・検索エンジンのクローラは data URI を画像として解決できず、同じ数百KBが - HTMLに二重・三重に載って書き出しサイズが倍増するだけになるため。実URLを指せる - ようになるのはホスト型公開(M2)から。 -- **共有URLに写真は含まれない**。URL長の限界を超えるため `encodeShare` が除外する。 - 受け手には写真なしのLPが表示される(UI上にも明示)。写真ごと渡すときはHTMLを - ダウンロードして共有する。 -- **自動保存は写真を落とすことがある**。`localStorage` の容量上限(概ね5MB)に達した - 場合、写真を除いて本文だけ保存し直す。その旨をUIで通知する。 - -### スワップの適用は「1パス同時置換」(利用者入力を後続スワップが食わないように) - -テンプレの文言差し替え(`swapHtml` / `applyRawSwaps` / プレビューの `SwapBoundary`)は、 -**全スワップを1回の走査で同時に適用**する(`src/lp/swap.ts` の `replaceAllInOnePass`)。 -スワップを配列順に `split().join()` で逐次適用すると、**先に差し込んだ利用者の入力が、 -後続スワップの `from` に一致した瞬間に再度置換される**ため。実際に次が起きていた: - -- サロンでプラン1の価格に `¥1,980`(=プラン2のスワップ元と同じ文字列)と入力すると、 - その値が消えてプラン2の価格に化ける -- 飲食テンプレの特徴に `鴨胸肉のロースト`(=別スワップの元文言)と入力すると別の料理名になる - -同じ位置で複数の `from` が一致しうる場合は**長い `from` を優先**する(部分文字列の取り違え防止)。 -`from` が空文字のスワップは無視する(無限ループ防止)。新しいスワップを足すときは、 -**利用者が入力しうる文字列と `from` が衝突しても壊れない**ことがこの機構で保証されるが、 -`templates.test.ts` の回帰テストは維持すること。 - -### 書き出しHTMLの自己完結化(Tailwind CDN 非依存)とテンプレ追加時の注意 - -書き出しHTML(`src/lp/export.ts`)は ` を入れても閉じタグ注入が起きない", async () => { - const evil = ""; - const html = await buildLpDocument( - ryokanTemplate, - answersWith({ shopName: evil, photos: [] }), - { paid: false } - ); - - const raw = extractJsonLd(html); - expect(raw).not.toContain(""); - expect(raw).not.toContain("<"); - // エスケープしてもJSONとしては等価で、値は元のまま読み出せる - const ld = JSON.parse(raw) as Record; - expect(ld.name).toBe(evil); - // 実行可能な " が含まれると - * script要素がそこで閉じてしまい任意のHTML(=XSS)を注入できてしまう。 - * `<` `>` `&` を \uXXXX へエスケープして閉じタグ注入を防ぐ(JSONとしては等価)。 - */ -function jsonLdHtml(t: IndustryTemplate, a: LpAnswers): string { - const doc: JsonLdDoc = { - "@context": "https://schema.org", - "@type": t.schemaType, - name: a.shopName, - }; - if (a.intro) doc.description = a.intro; - if (a.phone) doc.telephone = a.phone; - if (a.address) { - doc.address = { "@type": "PostalAddress", streetAddress: a.address }; - } - if (a.hours) doc.openingHours = a.hours; - if (a.area) doc.areaServed = a.area; - // image は意図的に入れない。写真は data URI で本文に埋め込まれており、検索エンジンの - // クローラは data URI を画像URLとして解決できない(リッチリザルトに使われない)。 - // 一方 JSON-LD に入れると同じ数百KBがHTMLに二重に載るだけなので、容量だけが増える。 - - const json = JSON.stringify(doc) - .replace(//g, "\\u003e") - .replace(/&/g, "\\u0026"); - return ``; -} - -/** - * 店名の先頭1文字をブランドカラーの角丸タイルに描いた favicon(SVG data URI)。 - * 外部ファイル無しで完結させるため、SVGをURLエンコードして href に直接埋め込む。 - * 文字は利用者入力なのでXMLエスケープしてから差し込む。 - */ -function faviconHref(t: IndustryTemplate, a: LpAnswers): string { - const initial = escapeHtml([...a.shopName.trim()][0] ?? "L"); - const svg = - `` + - `` + - `${initial}` + - ``; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; -} - -/** ... の完結ドキュメントに包む */ -function wrapDocument( - body: string, - t: IndustryTemplate, - a: LpAnswers, - paid: boolean -): string { - const title = `${a.shopName}|${a.tagline}`; - // og:image は出力しない。写真は data URI で本文に埋め込まれているが、SNSのクローラは - // data URI を取得できずサムネイルにできないため、数百KBを二重に載せるだけになる。 - // (公開URL上の実画像を指せるようになるホスト型公開=M2で対応する) - const ogp = paid - ? ` - - - - - ` - : ""; - return ` - - - - - ${escapeHtml(title)} - ${ogp} - - - - - - ${jsonLdHtml(t, a)} - - - - -${body} - -`; -} - -/** - * テンプレ + 回答から書き出し用の1枚HTMLを組み立てる。 - * 1. document.documentElement.lang を "ja" に固定する(デモの言語分岐のため。呼び出し側 - * (LpBuilder のマウント効果 / テストの beforeEach 等)が lang のライフサイクルを - * 所有しているため、ここでは固定するだけで退避・復元はしない。書き出し内で - * 退避/復元まで行うと、書き出し中に画面遷移した場合に呼び出し側の管理と競合し - * lang を誤って上書きするレースが生まれるため) - * 2. buildRenderPlan の並び(非表示セクションを除外し、写真セクションを差し込んだもの)に - * 従って各項目を実レンダリング(renderToStaticMarkup) - * - デモのセクション: swapHtml で文言を差し替え → applyRawSwaps で書き出し専用の - * 生HTML置換(rawSwaps)を適用 → linkifyCta でCTAボタンをリンク化 → - * 全幅でないデモ(align !== "full")は centerWrapClass のラッパーで中央寄せ - * - 写真セクション: PhotoShowcase を回答の写真で描画するだけ(内容は利用者入力そのもので - * デモの素文言を含まないため、swap/rawSwap/linkifyCta は適用しない) - * 3. 連結し、title/meta/JSON-LD/favicon/theme-color/OGP(フル版のみ)/Google Fonts/ - * インラインCSS/バッジ(無料版のみ)付きの1枚HTMLに包む - * 4. formatHtml で整形する - */ -export async function buildLpDocument( - t: IndustryTemplate, - a: LpAnswers, - opts: { paid: boolean } -): Promise { - const server = (await import( - "react-dom/server.browser" - )) as unknown as ServerRenderer; - - document.documentElement.lang = "ja"; - - const rendered: string[] = []; - for (const item of buildRenderPlan(t, a)) { - if (item.kind === "photos") { - rendered.push( - server.renderToStaticMarkup( - createElement(PhotoShowcase, { - photos: a.photos, - theme: t.photoSection.theme, - eyebrow: t.photoSection.eyebrow, - heading: t.photoSection.heading, - }) - ) - ); - continue; - } - const section = item.slot; - const entry = registry.find((e) => e.id === section.demoId); - if (!entry) { - throw new Error( - `registry にセクション "${section.demoId}" が見つかりません(npm run manifest を確認)` - ); - } - const Comp = await entry.load(); - const markup = server.renderToStaticMarkup(createElement(Comp)); - const swapped = swapHtml(markup, section.swaps, a); - const rawApplied = applyRawSwaps(swapped, section.rawSwaps, a); - const linked = linkifyCta(rawApplied, a.ctaLabel, a.ctaHref); - // 全幅でないデモ(証言カード等)は中央寄せのラッパーで包む(プレビューと同じ扱い) - const wrap = centerWrapClass(t, section.demoId); - rendered.push(wrap ? `
${linked}
` : linked); - } - - const doc = wrapDocument(rendered.join("\n"), t, a, opts.paid); - const withBadge = insertBeforeFooterClose(doc, opts.paid ? "" : badgeHtml()); - return formatHtml(withBadge); -} - -/** 生成済みHTMLをファイルとしてダウンロードさせる(Blob + createObjectURL) */ -export function downloadHtml(filename: string, html: string): void { - const blob = new Blob([html], { type: "text/html;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); -} diff --git a/src/lp/lp.css b/src/lp/lp.css deleted file mode 100644 index 6c23823..0000000 --- a/src/lp/lp.css +++ /dev/null @@ -1 +0,0 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background:0 0% 100%;--foreground:240 10% 3.9%;--card:0 0% 100%;--card-foreground:240 10% 3.9%;--primary:243 75% 59%;--primary-foreground:0 0% 98%;--secondary:240 4.8% 95.9%;--secondary-foreground:240 5.9% 10%;--muted:240 4.8% 95.9%;--muted-foreground:240 3.8% 46.1%;--accent:240 4.8% 95.9%;--accent-foreground:240 5.9% 10%;--destructive:0 84.2% 60.2%;--destructive-foreground:0 0% 98%;--border:240 5.9% 90%;--input:240 5.9% 90%;--ring:243 75% 59%;--radius:0.6rem}.dark{--background:240 10% 3.9%;--foreground:0 0% 98%;--card:240 8% 6%;--card-foreground:0 0% 98%;--primary:243 75% 65%;--primary-foreground:240 10% 3.9%;--secondary:240 3.7% 15.9%;--secondary-foreground:0 0% 98%;--muted:240 3.7% 13%;--muted-foreground:240 5% 64.9%;--accent:240 3.7% 15.9%;--accent-foreground:0 0% 98%;--destructive:0 62.8% 50.6%;--destructive-foreground:0 0% 98%;--border:240 3.7% 17%;--input:240 3.7% 17%;--ring:243 75% 65%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Hiragino Sans,Noto Sans JP,sans-serif;-webkit-font-smoothing:antialiased}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.inset-x-0{left:0;right:0}.inset-x-1{left:.25rem;right:.25rem}.-left-10{left:-2.5rem}.-right-12{right:-3rem}.-right-6{right:-1.5rem}.-top-3{top:-.75rem}.-top-6{top:-1.5rem}.bottom-0{bottom:0}.left-0{left:0}.left-1\/2{left:50%}.left-6{left:1.5rem}.right-6{right:1.5rem}.top-0{top:0}.top-10{top:2.5rem}.top-2{top:.5rem}.col-span-12{grid-column:span 12/span 12}.col-span-6{grid-column:span 6/span 6}.mx-auto{margin-left:auto;margin-right:auto}.my-6{margin-top:1.5rem;margin-bottom:1.5rem}.my-8{margin-top:2rem;margin-bottom:2rem}.-mt-4{margin-top:-1rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-14{margin-top:3.5rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.aspect-\[16\/10\]{aspect-ratio:16/10}.aspect-\[3\/4\]{aspect-ratio:3/4}.aspect-\[4\/3\]{aspect-ratio:4/3}.aspect-\[4\/5\]{aspect-ratio:4/5}.size-11{width:2.75rem;height:2.75rem}.size-14{width:3.5rem;height:3.5rem}.size-3{width:.75rem;height:.75rem}.size-3\.5{width:.875rem;height:.875rem}.size-4{width:1rem;height:1rem}.size-5{width:1.25rem;height:1.25rem}.size-6{width:1.5rem;height:1.5rem}.size-7{width:1.75rem;height:1.75rem}.size-8{width:2rem;height:2rem}.size-9{width:2.25rem;height:2.25rem}.size-full{width:100%;height:100%}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-28{height:7rem}.h-32{height:8rem}.h-4{height:1rem}.h-40{height:10rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[420px\]{height:420px}.h-\[460px\]{height:460px}.h-full{height:100%}.h-px{height:1px}.max-h-0{max-height:0}.max-h-72{max-height:18rem}.max-h-80{max-height:20rem}.min-h-56{min-height:14rem}.w-12{width:3rem}.w-16{width:4rem}.w-28{width:7rem}.w-3{width:.75rem}.w-32{width:8rem}.w-4{width:1rem}.w-40{width:10rem}.w-9{width:2.25rem}.w-\[280px\]{width:280px}.w-\[300px\]{width:300px}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.translate-x-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x:0px}.translate-x-4{--tw-translate-x:1rem}.translate-x-4,.translate-y-\[-3px\]{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-3px\]{--tw-translate-y:-3px}.-rotate-12{--tw-rotate:-12deg}.-rotate-12,.rotate-180{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate:180deg}.rotate-\[200deg\]{--tw-rotate:200deg}.rotate-\[200deg\],.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-7{gap:1.75rem}.gap-8{gap:2rem}.gap-px{gap:1px}.gap-x-10{-moz-column-gap:2.5rem;column-gap:2.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.gap-y-12{row-gap:3rem}.gap-y-16{row-gap:4rem}.gap-y-3{row-gap:.75rem}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-10>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2.5rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-2\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.625rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.625rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-neutral-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 229 229/var(--tw-divide-opacity,1))}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-width:1px}.border-2{border-width:2px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-t-0{border-top-width:0}.border-t-2{border-top-width:2px}.border-dashed{border-style:dashed}.border-dotted{border-style:dotted}.border-\[\#1f3a5f\]\/40{border-color:rgba(31,58,95,.4)}.border-\[\#5e6b4f\]{--tw-border-opacity:1;border-color:rgb(94 107 79/var(--tw-border-opacity,1))}.border-\[\#5e6b4f\]\/10{border-color:rgba(94,107,79,.1)}.border-\[\#5e6b4f\]\/15{border-color:rgba(94,107,79,.15)}.border-\[\#5e6b4f\]\/20{border-color:rgba(94,107,79,.2)}.border-\[\#5e6b4f\]\/25{border-color:rgba(94,107,79,.25)}.border-\[\#5e6b4f\]\/30{border-color:rgba(94,107,79,.3)}.border-\[\#b7410e\]{--tw-border-opacity:1;border-color:rgb(183 65 14/var(--tw-border-opacity,1))}.border-\[\#b7410e\]\/30{border-color:rgba(183,65,14,.3)}.border-amber-600{--tw-border-opacity:1;border-color:rgb(217 119 6/var(--tw-border-opacity,1))}.border-input{border-color:hsl(var(--input))}.border-neutral-100{--tw-border-opacity:1;border-color:rgb(245 245 245/var(--tw-border-opacity,1))}.border-neutral-200{--tw-border-opacity:1;border-color:rgb(229 229 229/var(--tw-border-opacity,1))}.border-neutral-300{--tw-border-opacity:1;border-color:rgb(212 212 212/var(--tw-border-opacity,1))}.border-neutral-900{--tw-border-opacity:1;border-color:rgb(23 23 23/var(--tw-border-opacity,1))}.border-stone-300{--tw-border-opacity:1;border-color:rgb(214 211 209/var(--tw-border-opacity,1))}.border-stone-300\/70{border-color:hsla(24,6%,83%,.7)}.border-stone-400{--tw-border-opacity:1;border-color:rgb(168 162 158/var(--tw-border-opacity,1))}.border-stone-400\/60{border-color:hsla(24,5%,64%,.6)}.border-stone-800{--tw-border-opacity:1;border-color:rgb(41 37 36/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.bg-\[\#162a45\]{--tw-bg-opacity:1;background-color:rgb(22 42 69/var(--tw-bg-opacity,1))}.bg-\[\#1c2b46\]{--tw-bg-opacity:1;background-color:rgb(28 43 70/var(--tw-bg-opacity,1))}.bg-\[\#1f3a5f\]{--tw-bg-opacity:1;background-color:rgb(31 58 95/var(--tw-bg-opacity,1))}.bg-\[\#5e6b4f\]{--tw-bg-opacity:1;background-color:rgb(94 107 79/var(--tw-bg-opacity,1))}.bg-\[\#5e6b4f\]\/15{background-color:rgba(94,107,79,.15)}.bg-\[\#b3753f\]{--tw-bg-opacity:1;background-color:rgb(179 117 63/var(--tw-bg-opacity,1))}.bg-\[\#b7410e\]{--tw-bg-opacity:1;background-color:rgb(183 65 14/var(--tw-bg-opacity,1))}.bg-\[\#e5341a\]{--tw-bg-opacity:1;background-color:rgb(229 52 26/var(--tw-bg-opacity,1))}.bg-\[\#efe9da\]{--tw-bg-opacity:1;background-color:rgb(239 233 218/var(--tw-bg-opacity,1))}.bg-\[\#f3ede1\]{--tw-bg-opacity:1;background-color:rgb(243 237 225/var(--tw-bg-opacity,1))}.bg-\[\#f3f1e7\]{--tw-bg-opacity:1;background-color:rgb(243 241 231/var(--tw-bg-opacity,1))}.bg-\[\#f3f1e7\]\/90{background-color:hsla(50,33%,93%,.9)}.bg-\[\#f5f1e8\]{--tw-bg-opacity:1;background-color:rgb(245 241 232/var(--tw-bg-opacity,1))}.bg-\[\#f8f5ef\]{--tw-bg-opacity:1;background-color:rgb(248 245 239/var(--tw-bg-opacity,1))}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-background{background-color:hsl(var(--background))}.bg-card{background-color:hsl(var(--card))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-input{background-color:hsl(var(--input))}.bg-neutral-50{--tw-bg-opacity:1;background-color:rgb(250 250 250/var(--tw-bg-opacity,1))}.bg-neutral-50\/40{background-color:hsla(0,0%,98%,.4)}.bg-neutral-900{--tw-bg-opacity:1;background-color:rgb(23 23 23/var(--tw-bg-opacity,1))}.bg-primary{background-color:hsl(var(--primary))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-stone-300{--tw-bg-opacity:1;background-color:rgb(214 211 209/var(--tw-bg-opacity,1))}.bg-stone-400{--tw-bg-opacity:1;background-color:rgb(168 162 158/var(--tw-bg-opacity,1))}.bg-stone-900{--tw-bg-opacity:1;background-color:rgb(28 25 23/var(--tw-bg-opacity,1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/50{background-color:hsla(0,0%,100%,.5)}.bg-white\/60{background-color:hsla(0,0%,100%,.6)}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-\[\#efe7d6\]{--tw-gradient-from:#efe7d6 var(--tw-gradient-from-position);--tw-gradient-to:hsla(41,44%,89%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-transparent{--tw-gradient-from:transparent var(--tw-gradient-from-position);--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-\[\#b7410e\]\/40{--tw-gradient-to:rgba(183,65,14,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),rgba(183,65,14,.4) var(--tw-gradient-via-position),var(--tw-gradient-to)}.via-\[\#f3ede1\]{--tw-gradient-to:hsla(40,43%,92%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),#f3ede1 var(--tw-gradient-via-position),var(--tw-gradient-to)}.via-stone-400\/50{--tw-gradient-to:hsla(24,5%,64%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),hsla(24,5%,64%,.5) var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-\[\#e6dcc6\]{--tw-gradient-to:#e6dcc6 var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.fill-current{fill:currentColor}.object-cover{-o-object-fit:cover;object-fit:cover}.p-10{padding:2.5rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.px-0{padding-left:0;padding-right:0}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-14{padding-top:3.5rem;padding-bottom:3.5rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-0\.5{padding-bottom:.125rem}.pb-1{padding-bottom:.25rem}.pb-4{padding-bottom:1rem}.pb-5{padding-bottom:1.25rem}.pb-6{padding-bottom:1.5rem}.pl-6{padding-left:1.5rem}.pt-0{padding-top:0}.pt-1{padding-top:.25rem}.pt-12{padding-top:3rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.text-right{text-align:right}.font-display{font-family:Playfair Display,ui-serif,Georgia,serif}.font-mincho{font-family:Shippori Mincho,"Noto Serif JP",serif}.font-sans{font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-8xl{font-size:6rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[13px\]{font-size:13px}.text-\[9px\]{font-size:9px}.text-\[clamp\(2\.75rem\2c 8vw\2c 7rem\)\]{font-size:clamp(2.75rem,8vw,7rem)}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-\[0\.95\]{line-height:.95}.leading-\[1\.05\]{line-height:1.05}.leading-\[1\.3\]{line-height:1.3}.leading-\[1\.4\]{line-height:1.4}.leading-loose{line-height:2}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.1em\]{letter-spacing:.1em}.tracking-\[0\.22em\]{letter-spacing:.22em}.tracking-\[0\.25em\]{letter-spacing:.25em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-\[0\.35em\]{letter-spacing:.35em}.tracking-\[0\.3em\]{letter-spacing:.3em}.tracking-\[0\.4em\]{letter-spacing:.4em}.tracking-tight{letter-spacing:-.025em}.tracking-tighter{letter-spacing:-.05em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.text-\[\#1f3a5f\]{--tw-text-opacity:1;color:rgb(31 58 95/var(--tw-text-opacity,1))}.text-\[\#3f4a35\]{--tw-text-opacity:1;color:rgb(63 74 53/var(--tw-text-opacity,1))}.text-\[\#5e6b4f\]{--tw-text-opacity:1;color:rgb(94 107 79/var(--tw-text-opacity,1))}.text-\[\#5e6b4f\]\/70{color:rgba(94,107,79,.7)}.text-\[\#5e6b4f\]\/80{color:rgba(94,107,79,.8)}.text-\[\#6b7a3a\]{--tw-text-opacity:1;color:rgb(107 122 58/var(--tw-text-opacity,1))}.text-\[\#7b2d3a\]{--tw-text-opacity:1;color:rgb(123 45 58/var(--tw-text-opacity,1))}.text-\[\#86a06d\]{--tw-text-opacity:1;color:rgb(134 160 109/var(--tw-text-opacity,1))}.text-\[\#b3753f\]{--tw-text-opacity:1;color:rgb(179 117 63/var(--tw-text-opacity,1))}.text-\[\#b7410e\]{--tw-text-opacity:1;color:rgb(183 65 14/var(--tw-text-opacity,1))}.text-\[\#b7410e\]\/30{color:rgba(183,65,14,.3)}.text-\[\#cdd4b6\]{--tw-text-opacity:1;color:rgb(205 212 182/var(--tw-text-opacity,1))}.text-\[\#e5341a\]{--tw-text-opacity:1;color:rgb(229 52 26/var(--tw-text-opacity,1))}.text-\[\#f3ede1\]{--tw-text-opacity:1;color:rgb(243 237 225/var(--tw-text-opacity,1))}.text-\[\#f3ede1\]\/50{color:hsla(40,43%,92%,.5)}.text-\[\#f3ede1\]\/70{color:hsla(40,43%,92%,.7)}.text-\[\#f3ede1\]\/80{color:hsla(40,43%,92%,.8)}.text-\[\#f3f1e7\]{--tw-text-opacity:1;color:rgb(243 241 231/var(--tw-text-opacity,1))}.text-\[\#f3f1e7\]\/70{color:hsla(50,33%,93%,.7)}.text-\[\#f3f1e7\]\/80{color:hsla(50,33%,93%,.8)}.text-\[\#f3f1e7\]\/90{color:hsla(50,33%,93%,.9)}.text-\[\#f5f1e8\]{--tw-text-opacity:1;color:rgb(245 241 232/var(--tw-text-opacity,1))}.text-\[\#f8f5ef\]{--tw-text-opacity:1;color:rgb(248 245 239/var(--tw-text-opacity,1))}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-amber-300\/70{color:rgba(252,211,77,.7)}.text-amber-300\/80{color:rgba(252,211,77,.8)}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-600\/40{color:rgba(217,119,6,.4)}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-neutral-400{--tw-text-opacity:1;color:rgb(163 163 163/var(--tw-text-opacity,1))}.text-neutral-50{--tw-text-opacity:1;color:rgb(250 250 250/var(--tw-text-opacity,1))}.text-neutral-500{--tw-text-opacity:1;color:rgb(115 115 115/var(--tw-text-opacity,1))}.text-neutral-600{--tw-text-opacity:1;color:rgb(82 82 82/var(--tw-text-opacity,1))}.text-neutral-700{--tw-text-opacity:1;color:rgb(64 64 64/var(--tw-text-opacity,1))}.text-neutral-900{--tw-text-opacity:1;color:rgb(23 23 23/var(--tw-text-opacity,1))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-stone-400{--tw-text-opacity:1;color:rgb(168 162 158/var(--tw-text-opacity,1))}.text-stone-500{--tw-text-opacity:1;color:rgb(120 113 108/var(--tw-text-opacity,1))}.text-stone-600{--tw-text-opacity:1;color:rgb(87 83 78/var(--tw-text-opacity,1))}.text-stone-700{--tw-text-opacity:1;color:rgb(68 64 60/var(--tw-text-opacity,1))}.text-stone-800{--tw-text-opacity:1;color:rgb(41 37 36/var(--tw-text-opacity,1))}.text-stone-900{--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline-offset-4{text-underline-offset:4px}.opacity-20{opacity:.2}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-\[0\.18\]{opacity:.18}.shadow-\[0_12px_40px_-20px_rgba\(63\2c 74\2c 53\2c 0\.5\)\]{--tw-shadow:0 12px 40px -20px rgba(63,74,53,.5);--tw-shadow-colored:0 12px 40px -20px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_20px_50px_-20px_rgba\(63\2c 74\2c 53\2c 0\.7\)\]{--tw-shadow:0 20px 50px -20px rgba(63,74,53,.7);--tw-shadow-colored:0 20px 50px -20px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_8px_30px_rgba\(183\2c 65\2c 14\2c 0\.12\)\]{--tw-shadow:0 8px 30px rgba(183,65,14,.12);--tw-shadow-colored:0 8px 30px var(--tw-shadow-color)}.shadow-\[0_8px_30px_rgba\(183\2c 65\2c 14\2c 0\.12\)\],.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-none{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.shadow-none,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.backdrop-blur{--tw-backdrop-blur:blur(8px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-700{transition-duration:.7s}.\[writing-mode\:vertical-rl\]{writing-mode:vertical-rl}.file\:border-0::file-selector-button{border-width:0}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:border-neutral-900:hover{--tw-border-opacity:1;border-color:rgb(23 23 23/var(--tw-border-opacity,1))}.hover\:bg-\[\#4b563f\]:hover{--tw-bg-opacity:1;background-color:rgb(75 86 63/var(--tw-bg-opacity,1))}.hover\:bg-\[\#5e6b4f\]:hover{--tw-bg-opacity:1;background-color:rgb(94 107 79/var(--tw-bg-opacity,1))}.hover\:bg-\[\#5e6b4f\]\/10:hover{background-color:rgba(94,107,79,.1)}.hover\:bg-\[\#9c360b\]:hover{--tw-bg-opacity:1;background-color:rgb(156 54 11/var(--tw-bg-opacity,1))}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-amber-700:hover{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.hover\:bg-destructive\/80:hover{background-color:hsl(var(--destructive)/.8)}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive)/.9)}.hover\:bg-neutral-50:hover{--tw-bg-opacity:1;background-color:rgb(250 250 250/var(--tw-bg-opacity,1))}.hover\:bg-neutral-800:hover{--tw-bg-opacity:1;background-color:rgb(38 38 38/var(--tw-bg-opacity,1))}.hover\:bg-primary\/80:hover{background-color:hsl(var(--primary)/.8)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary)/.9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary)/.8)}.hover\:bg-stone-100:hover{--tw-bg-opacity:1;background-color:rgb(245 245 244/var(--tw-bg-opacity,1))}.hover\:bg-stone-800:hover{--tw-bg-opacity:1;background-color:rgb(41 37 36/var(--tw-bg-opacity,1))}.hover\:bg-stone-900:hover{--tw-bg-opacity:1;background-color:rgb(28 25 23/var(--tw-bg-opacity,1))}.hover\:bg-transparent:hover{background-color:transparent}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-\[\#3f4a35\]:hover{--tw-text-opacity:1;color:rgb(63 74 53/var(--tw-text-opacity,1))}.hover\:text-\[\#b7410e\]:hover{--tw-text-opacity:1;color:rgb(183 65 14/var(--tw-text-opacity,1))}.hover\:text-\[\#f3f1e7\]:hover{--tw-text-opacity:1;color:rgb(243 241 231/var(--tw-text-opacity,1))}.hover\:text-\[\#f8f5ef\]:hover{--tw-text-opacity:1;color:rgb(248 245 239/var(--tw-text-opacity,1))}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-amber-200:hover{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.hover\:text-amber-300:hover{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.hover\:text-neutral-900:hover{--tw-text-opacity:1;color:rgb(23 23 23/var(--tw-text-opacity,1))}.hover\:text-stone-900:hover{--tw-text-opacity:1;color:rgb(28 25 23/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color:hsl(var(--ring))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:hsl(var(--background))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:translate-x-1{--tw-translate-x:0.25rem}.group:hover .group-hover\:scale-\[1\.03\],.group:hover .group-hover\:translate-x-1{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-\[1\.03\]{--tw-scale-x:1.03;--tw-scale-y:1.03}@media (prefers-reduced-motion:reduce){.motion-reduce\:transition-none{transition-property:none}}@media (min-width:640px){.sm\:flex{display:flex}.sm\:inline-flex{display:inline-flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-6{gap:1.5rem}.sm\:px-12{padding-left:3rem;padding-right:3rem}.sm\:px-14{padding-left:3.5rem;padding-right:3.5rem}.sm\:py-24{padding-top:6rem;padding-bottom:6rem}.sm\:py-28{padding-top:7rem;padding-bottom:7rem}.sm\:py-32{padding-top:8rem;padding-bottom:8rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-7xl{font-size:4.5rem;line-height:1}}@media (min-width:768px){.md\:col-span-12{grid-column:span 12/span 12}.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-3{grid-column:span 3/span 3}.md\:col-span-4{grid-column:span 4/span 4}.md\:col-span-5{grid-column:span 5/span 5}.md\:col-span-8{grid-column:span 8/span 8}.md\:col-span-9{grid-column:span 9/span 9}.md\:-my-3{margin-top:-.75rem;margin-bottom:-.75rem}.md\:block{display:block}.md\:flex{display:flex}.md\:hidden{display:none}.md\:-translate-y-3{--tw-translate-y:-0.75rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.md\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-\[1\.2fr_2fr\]{grid-template-columns:1.2fr 2fr}.md\:grid-cols-\[1\.4fr_1fr_1fr_1fr\]{grid-template-columns:1.4fr 1fr 1fr 1fr}.md\:divide-x>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;border-right-width:calc(1px*var(--tw-divide-x-reverse));border-left-width:calc(1px*(1 - var(--tw-divide-x-reverse)))}.md\:divide-y-0>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(0px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(0px*var(--tw-divide-y-reverse))}.md\:px-8{padding-left:2rem;padding-right:2rem}.md\:py-32{padding-top:8rem;padding-bottom:8rem}.md\:text-right{text-align:right}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-\[1\.75rem\]{font-size:1.75rem}.md\:shadow-\[0_20px_50px_-20px_rgba\(120\2c 45\2c 58\2c 0\.35\)\]{--tw-shadow:0 20px 50px -20px rgba(120,45,58,.35);--tw-shadow-colored:0 20px 50px -20px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-\[1\.4fr_1fr\]{grid-template-columns:1.4fr 1fr}.lg\:grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}}.\[\&_svg\]\:size-4 svg{width:1rem;height:1rem}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0} \ No newline at end of file diff --git a/src/lp/lp.test.tsx b/src/lp/lp.test.tsx deleted file mode 100644 index a315525..0000000 --- a/src/lp/lp.test.tsx +++ /dev/null @@ -1,1054 +0,0 @@ -import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { routeFromPath } from "@/App"; -import LpBuilder, { PreviewErrorBoundary } from "./LpBuilder"; -import { buildLpDocument, centerWrapClass } from "./export"; -import { fileToCompressedDataUrl } from "./photo"; -import PhotoShowcase, { photoAspectClass } from "./sections/PhotoShowcase"; -import FormStep, { type AnswerEditor } from "./steps/FormStep"; -import { encodeShare, decodeShare, loadDraft, type ShareState } from "./share"; -import { - LP_TEMPLATES, - clinicTemplate, - ryokanTemplate, - salonTemplate, -} from "./templates"; -import type { LpAnswers } from "./types"; - -/** - * ミセテLP の統合テスト(仕様§13)。 - * ウィザードUIの実レンダリング・書き出しHTMLの中身・共有URL往復・ - * ルーティング・プラン(無料 / ¥9,800 買い切り)ごとのUI挙動を確認する。 - */ - -// 写真の圧縮は canvas を使うため jsdom では動かない。取り込み後のUI(サムネイル・ -// 説明入力)を確認したいので、圧縮処理だけをスタブする(他のエクスポートは実物のまま)。 -vi.mock("./photo", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fileToCompressedDataUrl: vi.fn( - async () => "data:image/jpeg;base64,/9j/4AAQSkZJRg==" - ), - }; -}); - -/** 自動保存のデバウンス(LpBuilder の DRAFT_DEBOUNCE_MS)より確実に長い待ち時間 */ -const DRAFT_WAIT_MS = 2000; - -/** - * jsdom は window.scrollTo を実装しておらず、呼ぶと console.error に - * "Not implemented" を出す。ステップ移動でのスクロール位置リセット(LpBuilder)を - * 検証したいので、no-op のスパイに差し替える。 - */ -const scrollToSpy = vi.fn(); -window.scrollTo = scrollToSpy as unknown as typeof window.scrollTo; - -beforeEach(() => { - localStorage.clear(); - window.location.hash = ""; - scrollToSpy.mockClear(); -}); -afterEach(() => cleanup()); - -/** console.error/warn を捕捉する(pages.smoke.test.tsx と同じ方式)。 */ -function withCapture(fn: () => void): string[] { - const origErr = console.error; - const origWarn = console.warn; - const issues: string[] = []; - const capture = (...args: unknown[]) => - issues.push(args.map((a) => (typeof a === "string" ? a : String(a))).join(" ")); - console.error = capture; - console.warn = capture; - try { - fn(); - } finally { - console.error = origErr; - console.warn = origWarn; - } - return issues; -} - -describe("LpBuilder が例外・警告なくレンダリングできる", () => { - it("業種選択 → 旅館カードclick → フォーム画面へ遷移", () => { - const issues = withCapture(() => { - const { getByRole, getByText, unmount } = render( - {}} /> - ); - - // ① 業種選択画面 - getByText("業種を選んでください"); - - // 旅館カードをクリック(業種カードは実 が1件も残っていないこと(ボタン単位で判定。 - // 単純な非貪欲マッチを文書全体に掛けると別々のボタンをまたいで誤マッチしうるため) - const buttonInners = [...html.matchAll(/]*>([\s\S]*?)<\/button>/g)].map( - (m) => m[1] - ); - expect(buttonInners.some((inner) => inner.includes("ご予約はこちら"))).toBe( - false - ); - }); - - it("CTAリンク化: ctaHref が不許可スキーム(javascript:)のときは変換しない", async () => { - const answers: LpAnswers = { - ...customAnswers, - ctaHref: "javascript:alert(1)", - }; - const html = await buildLpDocument(ryokanTemplate, answers, { paid: false }); - expect(html).not.toContain("javascript:"); - }); -}); - -describe("routeFromPath", () => { - it('("/lp") === "lp"', () => { - expect(routeFromPath("/lp")).toBe("lp"); - }); -}); - -describe("共有: encodeShare/decodeShare 往復(ShareState経由・テンプレid込み)", () => { - it("salonテンプレのShareState(テンプレid + LpAnswers全フィールド)を往復できる", () => { - const state: ShareState = { t: salonTemplate.id, a: salonTemplate.defaults }; - const decoded = decodeShare(encodeShare(state)); - - expect(decoded).not.toBeNull(); - expect(decoded?.t).toBe(salonTemplate.id); - - const a = decoded!.a; - const expected = salonTemplate.defaults; - expect(a.shopName).toBe(expected.shopName); - expect(a.area).toBe(expected.area); - expect(a.tagline).toBe(expected.tagline); - expect(a.intro).toBe(expected.intro); - expect(a.features).toEqual(expected.features); - expect(a.plans).toEqual(expected.plans); - expect(a.phone).toBe(expected.phone); - expect(a.address).toBe(expected.address); - expect(a.hours).toBe(expected.hours); - expect(a.ctaLabel).toBe(expected.ctaLabel); - expect(a.ctaHref).toBe(expected.ctaHref); - }); -}); - -/** - * 共有URL経由でプレビュー(ステップ3)から起動し、④書き出し・共有画面まで進める。 - * (スティッキーなツールバーの「書き出しへ」は Suspense 配下ではなく常時 - * レンダリングされているため、プレビューの遅延ロード完了を待たずに押せる) - */ -function renderExportStep(onHome: () => void = () => {}) { - const view = renderPreviewStep(onHome); - fireEvent.click(view.getByRole("button", { name: /書き出しへ/ })); - return view; -} - -/** 共有URL経由で③プレビューから起動する */ -function renderPreviewStep(onHome: () => void = () => {}) { - const encoded = encodeShare({ - t: ryokanTemplate.id, - a: ryokanTemplate.defaults, - }); - window.location.hash = `#c=${encoded}`; - return render(); -} - -describe("無料プランの書き出し(回数の上限は無い)", () => { - /** jsdom に navigator.clipboard は無いため差し替える */ - function stubClipboard() { - const clipboard = { - writeText: vi.fn(async (_text: string) => {}), - }; - Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: clipboard, - }); - return clipboard; - } - - it("無料のまま書き出しステップを開いても、ダウンロード・コピーは押せる", () => { - const { getByRole, getByText, queryByText } = renderExportStep(); - - expect( - (getByRole("button", { name: "HTMLをダウンロード" }) as HTMLButtonElement) - .disabled - ).toBe(false); - expect( - (getByRole("button", { name: "HTMLをコピー" }) as HTMLButtonElement) - .disabled - ).toBe(false); - - // 無料版に伝えるのは「回数」ではなくバッジのこと - getByText(/書き出しは何回でもできます/); - expect(queryByText(/上限/)).toBeNull(); - }); - - it("何回コピーしても止まらない(かつての月3回の上限を超えても押せる)", async () => { - const clipboard = stubClipboard(); - const { getByRole, queryByText } = renderExportStep(); - - for (let i = 0; i < 4; i++) { - const copy = getByRole("button", { - name: "HTMLをコピー", - }) as HTMLButtonElement; - expect(copy.disabled).toBe(false); - fireEvent.click(copy); - await waitFor(() => - expect(clipboard.writeText).toHaveBeenCalledTimes(i + 1) - ); - } - - expect( - (getByRole("button", { name: "HTMLをコピー" }) as HTMLButtonElement) - .disabled - ).toBe(false); - expect(queryByText(/上限/)).toBeNull(); - }); - - it("無料でコピーしたHTMLにはバッジが入り、フル版に切り替えると消える", async () => { - const clipboard = stubClipboard(); - const { getByRole } = renderExportStep(); - - fireEvent.click(getByRole("button", { name: "HTMLをコピー" })); - await waitFor(() => - expect(clipboard.writeText).toHaveBeenCalledTimes(1) - ); - expect(clipboard.writeText.mock.calls[0][0]).toContain( - "Made with ミセテLP" - ); - - // 決済未接続のデモ切替でフル版にする - fireEvent.click(getByRole("button", { name: "デモモードでフル版を試す" })); - fireEvent.click(getByRole("button", { name: "HTMLをコピー" })); - await waitFor(() => - expect(clipboard.writeText).toHaveBeenCalledTimes(2) - ); - expect(clipboard.writeText.mock.calls[1][0]).not.toContain( - "Made with ミセテLP" - ); - }); -}); - -describe("書き出しステップの料金プラン表示", () => { - it("無料(¥0)と買い切り(¥9,800)の2枚だけが出て、月額表記は無い", () => { - const view = renderExportStep(); - - view.getByText("¥0"); - view.getByText("¥9,800"); - view.getByText("買い切り・税込"); - expect(view.queryAllByText(/\/月/)).toHaveLength(0); - expect(view.queryAllByText(/アップグレード/)).toHaveLength(0); - }); -}); - -describe("共有URLから開いたセッションの自動保存", () => { - it("自分のドラフトを上書きせず、共有用の別キーに保存する", () => { - vi.useFakeTimers(); - try { - // 自分の作業を自動保存しておく - const own = renderFormStep(); - fireEvent.change(own.getByLabelText("店名・屋号"), { - target: { value: "自分の宿" }, - }); - act(() => { - vi.advanceTimersByTime(DRAFT_WAIT_MS); - }); - expect(loadDraft()?.a.shopName).toBe("自分の宿"); - own.unmount(); - - // 他人の共有URLを開く(見に来た内容を優先するので復元バナーは出ない) - window.location.hash = `#c=${encodeShare({ - t: salonTemplate.id, - a: salonTemplate.defaults, - })}`; - const shared = render( {}} />); - expect(shared.queryByText("前回の続きから再開しますか?")).toBeNull(); - - // ③プレビューから②へ戻り、1文字だけ編集する - fireEvent.click(shared.getByRole("button", { name: /編集に戻る/ })); - fireEvent.change(shared.getByLabelText("店名・屋号"), { - target: { value: "共有された店を書き換え" }, - }); - act(() => { - vi.advanceTimersByTime(DRAFT_WAIT_MS); - }); - - // 自分のドラフトは無傷のまま、共有セッションの編集は別キーに残る - expect(loadDraft()?.a.shopName).toBe("自分の宿"); - expect(loadDraft("shared")?.a.shopName).toBe("共有された店を書き換え"); - } finally { - vi.useRealTimers(); - } - }); -}); - -describe("業種テンプレの選び直し", () => { - /** ②内容入力で店名を書き換えてから、①業種選択へ戻る */ - function editThenBackToStep1() { - const view = renderFormStep(); - fireEvent.change(view.getByLabelText("店名・屋号"), { - target: { value: "潮騒の宿かもめ" }, - }); - fireEvent.click(view.getByRole("button", { name: /業種選択に戻る/ })); - view.getByText("業種を選んでください"); - return view; -} - - it("同じ業種カードを選び直しても入力は消えない", () => { - const view = editThenBackToStep1(); - - fireEvent.click(view.getByRole("button", { name: /旅館・民宿/ })); - - view.getByText("LPの内容を入力してください"); - expect( - (view.getByLabelText("店名・屋号") as HTMLInputElement).value - ).toBe("潮騒の宿かもめ"); - }); - - it("別の業種へ切り替えるときは確認を挟み、「やめる」なら入力が残る", () => { - const view = editThenBackToStep1(); - - fireEvent.click(view.getByRole("button", { name: /サロン/ })); - - // 確認が出るだけで、まだ切り替わっていない - view.getByText("「サロン」に切り替えますか?"); - expect(view.queryByText("LPの内容を入力してください")).toBeNull(); - - fireEvent.click(view.getByRole("button", { name: "やめる" })); - expect(view.queryByText("「サロン」に切り替えますか?")).toBeNull(); - - // 元の業種に戻れば入力はそのまま - fireEvent.click(view.getByRole("button", { name: /旅館・民宿/ })); - expect( - (view.getByLabelText("店名・屋号") as HTMLInputElement).value - ).toBe("潮騒の宿かもめ"); - }); - - it("確認して切り替えると、切り替え先のサンプル文言に入れ替わる", () => { - const view = editThenBackToStep1(); - - fireEvent.click(view.getByRole("button", { name: /サロン/ })); - fireEvent.click(view.getByRole("button", { name: "切り替える" })); - - view.getByText("LPの内容を入力してください"); - expect( - (view.getByLabelText("店名・屋号") as HTMLInputElement).value - ).toBe(salonTemplate.defaults.shopName); - }); - - it("既定値のまま(入力なし)なら確認を挟まずに切り替わる", () => { - const view = render( {}} />); - - fireEvent.click(view.getByRole("button", { name: /サロン/ })); - - view.getByText("LPの内容を入力してください"); - expect( - (view.getByLabelText("店名・屋号") as HTMLInputElement).value - ).toBe(salonTemplate.defaults.shopName); - }); -}); - -describe("localStorage が使えない環境", () => { - it("getter が例外を投げてもビルダー全体は落ちない(プランは free 扱い)", () => { - const original = Object.getOwnPropertyDescriptor(window, "localStorage"); - Object.defineProperty(window, "localStorage", { - configurable: true, - get() { - // Cookie/サイトデータを拒否したブラウザの挙動(参照した時点で例外) - throw new Error("SecurityError: The operation is insecure."); - }, - }); - try { - const { getByText } = render( - {}} /> - ); - // ①業種選択が普通に描画される(白画面にならない) - getByText("業種を選んでください"); - } finally { - if (original) { - Object.defineProperty(window, "localStorage", original); - } else { - Reflect.deleteProperty(window, "localStorage"); - } - } - }); -}); - -describe("PreviewErrorBoundary", () => { - /** LpPreview に見立てた、必ず例外を投げるダミーコンポーネント。 */ - function Boom() { - throw new Error("プレビュー描画の模擬エラー"); - } - - it("子要素が例外を投げるとフォールバックUIを表示し、ボタンでonResetを呼ぶ", () => { - const onReset = vi.fn(); - // React はエラーバウンダリで捕捉した例外も開発モードでconsole.errorへ出力するため、 - // このテストの意図(フォールバック表示の確認)に無関係なノイズとして抑制する。 - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - try { - const { getByText, getByRole } = render( - - - - ); - - getByText( - "プレビューの表示に失敗しました。入力に戻ってやり直してください。" - ); - fireEvent.click(getByRole("button", { name: "入力に戻る" })); - expect(onReset).toHaveBeenCalledTimes(1); - } finally { - errorSpy.mockRestore(); - } - }); -}); - -describe("書き出しHTML: 暖簾ナビの屋号は書き出し専用のrawSwapsで店名に置き換わる", () => { - it("「奥山亭」「OKUYAMA TEI」が残らず、店名の文字数ぶんの屋号divが入る", async () => { - const answers: LpAnswers = { - ...ryokanTemplate.defaults, - shopName: "潮騒の宿かもめ", - }; - const html = await buildLpDocument(ryokanTemplate, answers, { - paid: false, - }); - - expect(html).not.toContain("奥山亭"); - expect(html).not.toContain("OKUYAMA TEI"); - - for (const ch of [...answers.shopName.replace(/\s+/g, "")]) { - const re = new RegExp( - `\\s*${ch}\\s*` - ); - expect(html, `"${ch}" の屋号divが見つからない`).toMatch(re); - } - }); -}); - -describe("書き出しHTML: テストモニアルの架空人名は含まれない", () => { - it("旅館/サロン/クリニックいずれのテンプレの書き出しにもデモの架空人名が残らない", async () => { - const ryokanHtml = await buildLpDocument( - ryokanTemplate, - ryokanTemplate.defaults, - { paid: false } - ); - expect(ryokanHtml).not.toContain("高瀬 美和 様"); - - const salonHtml = await buildLpDocument( - salonTemplate, - salonTemplate.defaults, - { paid: false } - ); - expect(salonHtml).not.toContain("三浦 美咲"); - - const clinicHtml = await buildLpDocument( - clinicTemplate, - clinicTemplate.defaults, - { paid: false } - ); - expect(clinicHtml).not.toContain("三宅 玲奈"); - }); -}); - -describe("書き出しHTML: クリニックテンプレのCTA()もリンク化される", () => { - it("ctaHref が の href に反映され、href=\"#\" のダミーリンクが残らない", async () => { - const answers: LpAnswers = { - ...clinicTemplate.defaults, - ctaHref: "tel:03-1234-5678", - }; - const html = await buildLpDocument(clinicTemplate, answers, { - paid: false, - }); - - expect(html).toContain('href="tel:03-1234-5678"'); - - const anchorMatches = [...html.matchAll(/]*)>([\s\S]*?)<\/a>/g)]; - const deadCtaRemains = anchorMatches.some( - ([, attrs, inner]) => - attrs.includes('href="#"') && inner.includes(answers.ctaLabel) - ); - expect(deadCtaRemains).toBe(false); - }); -}); - -describe("ステップ移動でスクロール位置と読み上げ位置が戻る", () => { - it("ステップが変わるとページ先頭へ戻り、その画面の見出しへフォーカスが移る", () => { - const view = render( {}} />); - scrollToSpy.mockClear(); - - // ①業種選択 → ②内容入力 - fireEvent.click(view.getByRole("button", { name: /旅館・民宿/ })); - expect(scrollToSpy).toHaveBeenCalledWith(0, 0); - expect(document.activeElement).toBe( - view.getByRole("heading", { name: "LPの内容を入力してください" }) - ); - - // ②内容入力 → ③プレビュー - scrollToSpy.mockClear(); - fireEvent.click(view.getByRole("button", { name: /プレビューへ/ })); - expect(scrollToSpy).toHaveBeenCalledWith(0, 0); - expect(document.activeElement).toBe( - view.getByRole("heading", { name: "プレビュー" }) - ); - - // ③プレビュー → ④書き出し(主役のダウンロードボタンが画面外に残らない) - scrollToSpy.mockClear(); - fireEvent.click(view.getByRole("button", { name: /書き出しへ/ })); - expect(scrollToSpy).toHaveBeenCalledWith(0, 0); - expect(document.activeElement).toBe( - view.getByRole("heading", { name: "書き出し・共有" }) - ); - }); -}); - -describe("無料版の「バッジなしで書き出す」導線", () => { - it("ビルダー内の料金プランへスクロールし、別サービスの料金ページへ遷移させない", () => { - const onHome = vi.fn(); - const scrollIntoView = vi - .spyOn(Element.prototype, "scrollIntoView") - .mockImplementation(() => {}); - try { - const view = renderExportStep(onHome); - const buy = view.getByRole("button", { - name: /バッジなしで書き出す/, - }); - - fireEvent.click(buy); - - // 同じ画面の料金プランへ送られる(見出しへフォーカス + スクロール) - const pricingHeading = view.getByRole("heading", { name: "料金プラン" }); - expect(document.activeElement).toBe(pricingHeading); - expect(scrollIntoView).toHaveBeenCalled(); - - // ビルダーから離脱していない(書き出し画面のまま・遷移コールバックも呼ばれない) - view.getByRole("heading", { name: "書き出し・共有" }); - expect(onHome).not.toHaveBeenCalled(); - expect(window.location.pathname).toBe("/"); - // 外部の料金ページ(/pricing)への導線そのものが無い - expect( - view.container.querySelector('a[href="/pricing"]') - ).toBeNull(); - } finally { - scrollIntoView.mockRestore(); - } - }); -}); - -describe("プレビューの表示幅切替", () => { - it("モバイル/タブレット/PCで、プレビュー枠の実際の width が切り替わる", () => { - const view = renderPreviewStep(); - const frame = () => - view.getByRole("region", { name: /幅のプレビュー/ }) as HTMLElement; - - // 初期はPC(100%) - expect(frame().style.width).toBe("100%"); - - fireEvent.click(view.getByRole("button", { name: "モバイル" })); - expect(frame().style.width).toBe("375px"); - - fireEvent.click(view.getByRole("button", { name: "タブレット" })); - expect(frame().style.width).toBe("768px"); - - fireEvent.click(view.getByRole("button", { name: "PC" })); - expect(frame().style.width).toBe("100%"); - - // 上限(max-width)ではなく実寸で描くので、狭い画面でも3つが同じにならない - expect(frame().className).toContain("shrink-0"); - }); - - it("全幅でないセクションは、書き出しと同じ中央寄せラッパーで包まれる", async () => { - const view = renderPreviewStep(); - const voice = ryokanTemplate.sections.find((s) => s.id === "voice")!; - const wrap = centerWrapClass(ryokanTemplate, voice.demoId); - expect(wrap).not.toBeNull(); - - // 遅延ロードしたセクションが、書き出しと同一のクラスのラッパー配下に出る - await waitFor(() => - expect( - view.container.querySelector(`div[class="${wrap}"]`) - ).not.toBeNull() - ); - }); -}); - -describe("フォームの入力とセクションの対応", () => { - it("セクションをOFFにすると、対応する入力グループに非表示の注記が出る", () => { - const view = renderFormStep(); - const voice = ryokanTemplate.sections.find((s) => s.id === "voice"); - expect(voice?.optional).toBe(true); - - // どのセクションに出る入力なのかが常に示されている - view.getByText(`「${voice!.label}」セクションに表示されます。`); - expect(view.queryByText("現在このセクションは非表示です")).toBeNull(); - - fireEvent.click(view.getByRole("switch", { name: voice!.label })); - view.getByText("現在このセクションは非表示です"); - - // 戻せば注記も消える - fireEvent.click(view.getByRole("switch", { name: voice!.label })); - expect(view.queryByText("現在このセクションは非表示です")).toBeNull(); - }); - - it("消せるセクションはすべて、対応する入力グループから辿れる(全テンプレ)", () => { - /** 表示だけを見るテストなので、書き換え操作は受け取るだけで何もしない */ - const noopEditor: AnswerEditor = { - update: () => {}, - updateFeature: () => {}, - updatePlan: () => {}, - updateTestimonial: () => {}, - updatePhotos: () => {}, - }; - - for (const t of LP_TEMPLATES) { - const view = render( - {}} - onNext={() => {}} - /> - ); - // 任意セクション(OFFにできる=入力が黙って消えうる)と写真セクションのすべてに - // 対応する入力グループがあり、そこにセクション名が書かれている - const labels = [ - ...t.sections.filter((s) => s.optional).map((s) => s.label), - t.photoSection.label, - ]; - for (const label of labels) { - view.getByText(`「${label}」セクションに表示されます。`); - } - view.unmount(); - } - }); -}); - -describe("写真の切り抜き比率", () => { - /** 隠し にファイルを流し込む(jsdom では files に代入できない) */ - function selectPhoto(input: HTMLInputElement, name: string) { - Object.defineProperty(input, "files", { - value: [new File(["dummy"], name, { type: "image/jpeg" })], - configurable: true, - }); - fireEvent.change(input); - } - - it("比率決定関数はプレビュー(PhotoShowcase)とフォーム(サムネイル)で共有される", async () => { - // 枚数で比率が変わる(同じ関数がひとつの真実) - expect(photoAspectClass(1)).not.toBe(photoAspectClass(2)); - expect(photoAspectClass(2)).not.toBe(photoAspectClass(3)); - - // ① LP側(PhotoShowcase) - const photos = [ - { dataUrl: "data:image/jpeg;base64,AAAA", alt: "露天風呂" }, - { dataUrl: "data:image/jpeg;base64,BBBB", alt: "客室" }, - ]; - const showcase = render( - - ); - const lpFrame = showcase.getByAltText("露天風呂").parentElement; - expect(lpFrame?.className).toContain(photoAspectClass(2)); - showcase.unmount(); - - // ② フォーム側(サムネイル): 同じ枚数なら同じ比率になる - const view = renderFormStep(); - const input = view.getByLabelText("写真を選ぶ") as HTMLInputElement; - - selectPhoto(input, "one.jpg"); - await waitFor(() => - expect(view.getAllByLabelText(/写真の説明/)).toHaveLength(1) - ); - expect(view.getAllByAltText("説明が未入力の写真")[0].className).toContain( - photoAspectClass(1) - ); - - selectPhoto(input, "two.jpg"); - await waitFor(() => - expect(view.getAllByLabelText(/写真の説明/)).toHaveLength(2) - ); - for (const img of view.getAllByAltText("説明が未入力の写真")) { - expect(img.className).toContain(photoAspectClass(2)); - } - // 実際の切り抜き比率もフォーム上に明示する - view.getByText(/LPでは 4:3/); - }); -}); diff --git a/src/lp/lpPlan.ts b/src/lp/lpPlan.ts deleted file mode 100644 index ba1b7ff..0000000 --- a/src/lp/lpPlan.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * ミセテLP の料金プラン・Stripeリンク・サイトURLの単一の真実。 - * ビルダーUI(LpBuilder)のゲーティングと表示はすべてここを参照する。 - * 既存プラン(src/lib/plan.ts)とは別サービスのため独立した定義を持つ。 - * - * ── なぜ月額をやめて買い切りにしたのか ── - * 作るのは「1枚のHTMLファイル」で、持ち帰った時点で利用者の手元に残る。 - * 毎月こちらが何かを提供し続ける種類の商品ではないのに月額を取ると、 - * 解約が止まらないうえ、こちらにも継続的な更新・サポートの義務が生まれる。 - * 姉妹のコンポーネント集(src/lib/plan.ts)と同じく、買い切りの2段に揃える。 - * - * ── なぜ「無料は書き出し月3回まで」の上限を捨てたのか ── - * 1. LPは一度で決まらない。書き出して実機で確認し、直してまた書き出す。 - * 月3回の上限は、利用者がまだ価値を感じていない最初の作業のさなかで手を止める。 - * 止められた人は買うのではなく、そのまま帰ってしまう。 - * 2. プラン状態も回数もブラウザの localStorage にあり、編集すれば外せる。 - * 守れない壁は、規約どおりに使う正直な利用者だけを止める。 - * 3. 実際に効く壁は、書き出したHTMLに焼き込まれるフッターのバッジのほう。 - * 無料で公開されたLPがそのまま宣伝になる(Carrd・Tally と同じ形)。 - */ -import { useEffect, useState } from "react"; - -/** - * ミセテLP のプラン識別子。コンポーネント集(src/lib/plan.ts)と同じ - * 「無料 / 買い切り」の2段だが、別サービスとして独立に持つ - * (片方の値を変えるともう片方が壊れる事故を避けるため)。 - */ -export type LpPlanId = "free" | "full"; - -export interface LpPlanTier { - id: LpPlanId; - name: string; - /** 円。買い切りなので月額ではない。0 は無料。 */ - price: number; - /** 価格の表示文字列(例 "¥9,800") */ - priceLabel: string; - /** 価格に添える補助表記(例 "買い切り・税込")。無料版は持たない。 */ - priceNote?: string; - highlight?: boolean; - features: string[]; -} - -/** ミセテLP の買い切り版を持っているか(バッジ非表示・OGP出力の解放判定) */ -export function isLpPaid(plan: LpPlanId): boolean { - return plan === "full"; -} - -export const LP_PLANS: readonly LpPlanTier[] = [ - { - id: "free", - name: "無料", - price: 0, - priceLabel: "¥0", - features: [ - "4業種のテンプレート・写真・お客様の声まで全機能", - "書き出し:無制限", - "共有URL:無制限", - "複数プロジェクト保存", - "フッターに「Made with ミセテLP」バッジが入る", - ], - }, - { - id: "full", - name: "フル", - price: 9800, - priceLabel: "¥9,800", - priceNote: "買い切り・税込", - highlight: true, - features: [ - "無料版のすべて", - "フッターのバッジなし", - "OGP(SNSでの見え方)の設定", - "商用利用OK(自分のお店でもクライアント納品でも)", - "買い切り。これ以降の費用はかからない", - ], - }, -] as const; - -/** import.meta.env の値を、空文字を除いた string | undefined に正規化する */ -function envString(v: unknown): string | undefined { - return typeof v === "string" && v.length > 0 ? v : undefined; -} - -/** Stripe の決済リンク(買い切り1本)。未設定なら null(= デモモード)。 */ -export function getStripeLink(): string | null { - return envString(import.meta.env.VITE_STRIPE_LINK_LP) ?? null; -} - -/** 公開サイトのベースURL(共有URL・OGP生成に使用) */ -export const SITE_URL = envString(import.meta.env.VITE_SITE_URL) ?? "https://misete-lp.pages.dev"; - -const LP_PLAN_KEY = "lp:plan"; - -/** - * ミセテLP 専用のプラン状態(localStorage キー: "lp:plan")。 - * 既存 Studio の usePlan()(src/lib/plan.ts, キー "cs:plan")と同じ実装パターン - * (初期値をlocalStorageから読み出し・変更のたびに保存)を踏襲するが、キーを分離した - * 別サービスの状態として独立に持つ(Studio 側の "cs:plan" には一切触れない)。 - * 月額時代の値("pro" / "studio")が残っていても、既知でない値として "free" に倒れる。 - */ -export function useLpPlan(): { - plan: LpPlanId; - setPlan: (p: LpPlanId) => void; -} { - const [plan, setPlanState] = useState(() => { - // Cookie/サイトデータを拒否した環境では localStorage は「未定義」ではなく - // getter 自体が例外(SecurityError)を投げる。typeof ガードでは防げず、ここは - // LpBuilder の最初のフックのためページ全体が白画面になる。保存側(useEffect)と - // 同じく握りつぶして既定値に倒す。 - try { - const v = localStorage.getItem(LP_PLAN_KEY) as LpPlanId | null; - return v === "full" || v === "free" ? v : "free"; - } catch { - return "free"; - } - }); - useEffect(() => { - try { - localStorage.setItem(LP_PLAN_KEY, plan); - } catch { - /* noop */ - } - }, [plan]); - return { plan, setPlan: setPlanState }; -} diff --git a/src/lp/photo.ts b/src/lp/photo.ts deleted file mode 100644 index 1753d5b..0000000 --- a/src/lp/photo.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * 写真の取り込み(リサイズ+圧縮)。 - * - * 書き出しHTMLは1枚で完結させる方針のため、写真は data URI として本文に埋め込む。 - * スマホの写真をそのまま入れるとHTMLが数MBになり実用に耐えないので、取り込み時に - * 長辺を縮小し、JPEG品質を段階的に落として上限バイト数に収める。 - */ - -/** 縮小後の長辺(px)。LPの全幅表示に足り、かつ容量を抑えられる値 */ -const MAX_EDGE = 1600; - -/** data URI 1枚あたりのおおよその上限(文字数)。3枚でも localStorage に収まる範囲に */ -export const MAX_PHOTO_DATA_URL_LENGTH = 900_000; - -/** 試行するJPEG品質(先頭から順に試し、上限に収まった時点で採用) */ -const QUALITY_STEPS = [0.82, 0.72, 0.62, 0.5, 0.4]; - -/** 受け付ける画像MIMEタイプ */ -const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; - -export class PhotoImportError extends Error {} - -/** File を として読み込む(object URL は必ず解放する) */ -function loadImage(file: File): Promise { - return new Promise((resolve, reject) => { - const url = URL.createObjectURL(file); - const img = new Image(); - img.onload = () => { - URL.revokeObjectURL(url); - resolve(img); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new PhotoImportError("画像を読み込めませんでした。")); - }; - img.src = url; - }); -} - -/** 長辺が MAX_EDGE に収まる描画サイズを求める(拡大はしない) */ -function fitSize(w: number, h: number): { width: number; height: number } { - const longest = Math.max(w, h); - if (longest <= MAX_EDGE) return { width: w, height: h }; - const scale = MAX_EDGE / longest; - return { width: Math.round(w * scale), height: Math.round(h * scale) }; -} - -/** - * 画像ファイルを、LPに埋め込める圧縮済み data URI(JPEG)に変換する。 - * 上限に収まらない場合は PhotoImportError を投げる(呼び出し側で利用者に提示する)。 - */ -export async function fileToCompressedDataUrl(file: File): Promise { - if (!ACCEPTED_TYPES.includes(file.type)) { - throw new PhotoImportError( - "JPEG / PNG / WebP / GIF の画像ファイルを選んでください。" - ); - } - - const img = await loadImage(file); - const { width, height } = fitSize(img.naturalWidth, img.naturalHeight); - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - if (!ctx) { - throw new PhotoImportError("この環境では画像を処理できませんでした。"); - } - // 透過PNGをJPEGにすると背景が黒く落ちるため、白で下地を塗ってから描画する - ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, width, height); - ctx.drawImage(img, 0, 0, width, height); - - for (const quality of QUALITY_STEPS) { - const dataUrl = canvas.toDataURL("image/jpeg", quality); - if (dataUrl.length <= MAX_PHOTO_DATA_URL_LENGTH) return dataUrl; - } - throw new PhotoImportError( - "画像の容量が大きすぎます。もう少し小さい写真を選んでください。" - ); -} diff --git a/src/lp/sections/PhotoShowcase.tsx b/src/lp/sections/PhotoShowcase.tsx deleted file mode 100644 index 2694ac9..0000000 --- a/src/lp/sections/PhotoShowcase.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * 写真ギャラリーセクション。 - * - * 既存デモ(registry)は「外部画像なし」の規約でSVG装飾のみのため、利用者が撮った - * 実物の写真を載せる場所が無い。このセクションだけはミセテLP独自のコンポーネントとして - * 用意し、プレビュー(LpPreview)と書き出し(export.ts の renderToStaticMarkup)の - * 両方から同じ実体を描画する。 - * - * 写真は data URI(圧縮済みJPEG)なので、書き出しHTMLは外部リソース無しで完結する。 - * 配色・書体は PhotoTheme でテンプレの世界観に合わせる(クラス文字列はテンプレ側 - * templates.ts に literal で書かれ、npm run lp:css の --content 対象に含める)。 - */ -import { cn } from "@/lib/utils"; -import type { LpPhoto, PhotoTheme } from "../types"; - -/** - * 枚数に応じた写真の縦横比。1枚=主役、2枚=横並び、3枚=縦長3連。 - * - * フォームのサムネイル(steps/PhotoUploader)もこの関数を使う。切り抜きの構図は - * 枚数で変わるため、サムネイルが固定比率だと「フォームで見た構図」と「LPの構図」が - * 食い違い、3枚目を足した瞬間に1枚目の見え方が変わる理由も分からなくなる。 - */ -export function photoAspectClass(count: number): string { - if (count <= 1) return "aspect-[16/10]"; - if (count === 2) return "aspect-[4/3]"; - return "aspect-[4/5]"; -} - -/** photoAspectClass と対になる、利用者に見せる比率の表記(例: "16:10") */ -export function photoAspectLabel(count: number): string { - if (count <= 1) return "16:10"; - if (count === 2) return "4:3"; - return "4:5"; -} - -/** 枚数に応じたグリッド列数 */ -function gridFor(count: number): string { - if (count <= 1) return "grid-cols-1"; - if (count === 2) return "grid-cols-1 sm:grid-cols-2"; - return "grid-cols-1 sm:grid-cols-3"; -} - -export default function PhotoShowcase({ - photos, - theme, - eyebrow, - heading, -}: { - photos: LpPhoto[]; - theme: PhotoTheme; - eyebrow: string; - heading: string; -}) { - if (photos.length === 0) return null; - const count = photos.length; - - return ( -
-
-
-

- {eyebrow} -

-

- {heading} -

- -
- -
- {photos.map((photo, i) => ( -
-
- {/* eslint-disable-next-line jsx-a11y/img-redundant-alt -- alt は利用者入力 */} - {photo.alt} -
- {photo.alt ? ( -
- {photo.alt} -
- ) : null} -
- ))} -
-
-
- ); -} diff --git a/src/lp/share.test.ts b/src/lp/share.test.ts deleted file mode 100644 index e2a3723..0000000 --- a/src/lp/share.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * 共有状態の正規化・自動保存まわりのテスト。 - * - * 共有URL(#c=)は第三者が任意のJSONを組み立てて配布できるため、ここが緩むと - * プレビュー描画が想定外の形で例外を投げアプリ全体が落ちる(過去に実際に起きた欠陥)。 - * 同時に、機能追加で LpAnswers にフィールドが増えても「以前に発行された共有URL・ - * 保存済みプロジェクト」を壊さないことも保証する必要がある。その両立を検証する。 - */ -import { beforeEach, describe, expect, it } from "vitest"; -import { - clearDraft, - decodeShare, - encodeShare, - listProjects, - loadDraft, - saveDraft, - saveProject, - type ShareState, -} from "./share"; -import { LP_TEMPLATES } from "./templates"; -import { MAX_PHOTOS, type LpAnswers } from "./types"; - -const template = LP_TEMPLATES[0]; - -function baseAnswers(): LpAnswers { - return structuredClone(template.defaults); -} - -/** 旧バージョン(testimonials/photos/hiddenSections が無い時代)の回答を再現する */ -function legacyAnswers(): Record { - const a = baseAnswers() as unknown as Record; - delete a.testimonials; - delete a.photos; - delete a.hiddenSections; - return a; -} - -/** 任意のオブジェクトを共有ハッシュ文字列にする(encodeShare は写真を落とすため素で作る) */ -function toHash(value: unknown): string { - const bytes = new TextEncoder().encode(JSON.stringify(value)); - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -beforeEach(() => { - localStorage.clear(); -}); - -describe("decodeShare の正規化", () => { - it("正しい状態はそのまま復元できる", () => { - const state: ShareState = { t: template.id, a: baseAnswers() }; - const decoded = decodeShare(encodeShare(state)); - expect(decoded?.t).toBe(template.id); - expect(decoded?.a.shopName).toBe(state.a.shopName); - expect(decoded?.a.testimonials).toHaveLength(3); - }); - - it("旧形式(新フィールドなし)もテンプレ既定値で補って復元できる", () => { - const decoded = decodeShare(toHash({ t: template.id, a: legacyAnswers() })); - expect(decoded).not.toBeNull(); - expect(decoded?.a.testimonials).toHaveLength(3); - expect(decoded?.a.photos).toEqual([]); - expect(decoded?.a.hiddenSections).toEqual([]); - }); - - it("骨格が壊れていれば復元しない(features 欠落・plans長さ違い・型違い)", () => { - const a = baseAnswers() as unknown as Record; - delete a.features; - expect(decodeShare(toHash({ t: template.id, a }))).toBeNull(); - - const b = baseAnswers() as unknown as Record; - b.plans = [{ name: "x", price: "y", desc: "z" }]; - expect(decodeShare(toHash({ t: template.id, a: b }))).toBeNull(); - - const c = baseAnswers() as unknown as Record; - c.shopName = 42; - expect(decodeShare(toHash({ t: template.id, a: c }))).toBeNull(); - }); - - it("存在しないテンプレIDは復元しない", () => { - expect( - decodeShare(toHash({ t: "no-such-template", a: baseAnswers() })) - ).toBeNull(); - }); - - it("data:image 以外の写真URLは受け付けない(imgのsrcに任意スキームを載せない)", () => { - const a = baseAnswers() as unknown as Record; - a.photos = [{ dataUrl: "javascript:alert(1)", alt: "x" }]; - const decoded = decodeShare(toHash({ t: template.id, a })); - // 不正な写真配列は丸ごと空に正規化される(復元自体は成功する) - expect(decoded).not.toBeNull(); - expect(decoded?.a.photos).toEqual([]); - }); - - it("写真は上限枚数までに切り詰められる", () => { - const a = baseAnswers() as unknown as Record; - a.photos = Array.from({ length: MAX_PHOTOS + 2 }, (_, i) => ({ - dataUrl: `data:image/jpeg;base64,AAAA${i}`, - alt: `写真${i}`, - })); - const decoded = decodeShare(toHash({ t: template.id, a })); - expect(decoded?.a.photos).toHaveLength(MAX_PHOTOS); - }); - - it("壊れた文字列・不正JSONでも例外を投げず null を返す", () => { - expect(decodeShare("!!!not-base64!!!")).toBeNull(); - expect(decodeShare(toHash("just a string"))).toBeNull(); - expect(decodeShare("")).toBeNull(); - }); -}); - -describe("encodeShare", () => { - it("共有URLには写真を載せない(URL長の限界を超えるため)", () => { - const a = baseAnswers(); - a.photos = [{ dataUrl: `data:image/jpeg;base64,${"A".repeat(5000)}`, alt: "露天風呂" }]; - const encoded = encodeShare({ t: template.id, a }); - expect(encoded.length).toBeLessThan(4000); - expect(decodeShare(encoded)?.a.photos).toEqual([]); - }); -}); - -describe("保存済みプロジェクト", () => { - it("保存して読み出せる", () => { - const state: ShareState = { t: template.id, a: baseAnswers() }; - expect(saveProject("うちの宿", state)).toBe(true); - const list = listProjects(); - expect(list).toHaveLength(1); - expect(list[0].name).toBe("うちの宿"); - expect(list[0].state.a.shopName).toBe(state.a.shopName); - }); - - it("旧形式で保存されたプロジェクトも新フィールドを補って開ける", () => { - localStorage.setItem( - "misete:projects", - JSON.stringify([ - { - id: "old-1", - name: "以前の下書き", - updatedAt: 1, - state: { t: template.id, a: legacyAnswers() }, - }, - ]) - ); - const list = listProjects(); - expect(list).toHaveLength(1); - expect(list[0].state.a.testimonials).toHaveLength(3); - }); - - it("復元できない壊れた項目は一覧から除外する", () => { - localStorage.setItem( - "misete:projects", - JSON.stringify([ - { id: "broken", name: "壊れ", updatedAt: 1, state: { t: "nope", a: {} } }, - { - id: "ok", - name: "正常", - updatedAt: 2, - state: { t: template.id, a: baseAnswers() }, - }, - ]) - ); - const list = listProjects(); - expect(list.map((p) => p.name)).toEqual(["正常"]); - }); -}); - -describe("自動保存(ドラフト)", () => { - it("保存して読み出せる/破棄できる", () => { - const state: ShareState = { t: template.id, a: baseAnswers() }; - expect(saveDraft(state)).toBe("saved"); - expect(loadDraft()?.a.shopName).toBe(state.a.shopName); - clearDraft(); - expect(loadDraft()).toBeNull(); - }); - - it("容量超過のときは写真を落として本文だけでも保存する", () => { - const original = Storage.prototype.setItem; - let callCount = 0; - // 1回目(写真あり)は容量超過で失敗、2回目(写真なし)は成功する状況を再現する - Storage.prototype.setItem = function (key: string, value: string) { - callCount += 1; - if (callCount === 1) throw new Error("QuotaExceededError"); - return original.call(this, key, value); - }; - try { - const a = baseAnswers(); - a.photos = [{ dataUrl: "data:image/jpeg;base64,AAAA", alt: "外観" }]; - expect(saveDraft({ t: template.id, a })).toBe("saved-without-photos"); - expect(loadDraft()?.a.photos).toEqual([]); - expect(loadDraft()?.a.shopName).toBe(a.shopName); - } finally { - Storage.prototype.setItem = original; - } - }); - - it("2回とも失敗したら failed を返す(例外は投げない)", () => { - const original = Storage.prototype.setItem; - Storage.prototype.setItem = () => { - throw new Error("QuotaExceededError"); - }; - try { - expect(saveDraft({ t: template.id, a: baseAnswers() })).toBe("failed"); - } finally { - Storage.prototype.setItem = original; - } - }); -}); diff --git a/src/lp/share.ts b/src/lp/share.ts deleted file mode 100644 index 106a15f..0000000 --- a/src/lp/share.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { LP_TEMPLATES } from "./templates"; -import { - MAX_PHOTOS, - type Feature, - type LpAnswers, - type LpPhoto, - type PricePlan, - type Testimonial, -} from "./types"; - -/** 共有URLに載せる状態。t = テンプレID、a = 回答一式。 */ -export interface ShareState { - t: string; - a: LpAnswers; -} - -/** LpAnswers のうち文字列であるべきフィールド一覧(深い形状検証に使う) */ -const STRING_FIELDS = [ - "shopName", - "area", - "tagline", - "intro", - "phone", - "address", - "hours", - "ctaLabel", - "ctaHref", -] as const satisfies readonly (keyof LpAnswers)[]; - -function isFeature(v: unknown): v is Feature { - return ( - typeof v === "object" && - v !== null && - typeof (v as { title?: unknown }).title === "string" && - typeof (v as { desc?: unknown }).desc === "string" - ); -} - -function isPricePlan(v: unknown): v is PricePlan { - return ( - typeof v === "object" && - v !== null && - typeof (v as { name?: unknown }).name === "string" && - typeof (v as { price?: unknown }).price === "string" && - typeof (v as { desc?: unknown }).desc === "string" - ); -} - -function isTestimonial(v: unknown): v is Testimonial { - return ( - typeof v === "object" && - v !== null && - typeof (v as { headline?: unknown }).headline === "string" && - typeof (v as { body?: unknown }).body === "string" && - typeof (v as { name?: unknown }).name === "string" && - typeof (v as { meta?: unknown }).meta === "string" - ); -} - -/** - * 写真1枚の検証。dataUrl は必ず `data:image/` で始まることまで確認する。 - * 共有URL・保存データは第三者が組み立てられるため、`` に任意スキーム - * (javascript: など)が入り込む余地を残さない。 - */ -function isPhoto(v: unknown): v is LpPhoto { - if (typeof v !== "object" || v === null) return false; - const dataUrl = (v as { dataUrl?: unknown }).dataUrl; - const alt = (v as { alt?: unknown }).alt; - return ( - typeof dataUrl === "string" && - dataUrl.startsWith("data:image/") && - typeof alt === "string" - ); -} - -/** - * 共有URL・保存データから復元した回答を、安全な LpAnswers に正規化する。 - * - * - 骨格(文字列フィールド / features / plans)が壊れていれば null(=復元しない)。 - * 共有URLは第三者が任意のJSONを組み立てて配布できるため、ここを緩めると - * プレビュー描画(LpPreview/SwapBoundary)が想定外の形で例外を投げクラッシュしうる。 - * - 後から追加されたフィールド(testimonials / photos / hiddenSections)は、 - * 欠落・型不一致ならテンプレの既定値(または空)で補う。これにより機能追加前に - * 発行された共有URL・保存済みプロジェクトも壊さずに開ける。 - */ -function normalizeLpAnswers(templateId: string, a: unknown): LpAnswers | null { - if (typeof a !== "object" || a === null) return null; - const obj = a as Record; - for (const key of STRING_FIELDS) { - if (typeof obj[key] !== "string") return null; - } - if ( - !Array.isArray(obj.features) || - obj.features.length !== 3 || - !obj.features.every(isFeature) - ) { - return null; - } - if ( - !Array.isArray(obj.plans) || - obj.plans.length !== 3 || - !obj.plans.every(isPricePlan) - ) { - return null; - } - - const fallback = LP_TEMPLATES.find((t) => t.id === templateId)?.defaults; - const testimonials = - Array.isArray(obj.testimonials) && - obj.testimonials.length === 3 && - obj.testimonials.every(isTestimonial) - ? (obj.testimonials as [Testimonial, Testimonial, Testimonial]) - : fallback?.testimonials; - if (!testimonials) return null; - - const photos = - Array.isArray(obj.photos) && obj.photos.every(isPhoto) - ? (obj.photos as LpPhoto[]).slice(0, MAX_PHOTOS) - : []; - - const hiddenSections = - Array.isArray(obj.hiddenSections) && - obj.hiddenSections.every((v) => typeof v === "string") - ? (obj.hiddenSections as string[]) - : []; - - return { - ...(obj as unknown as LpAnswers), - testimonials, - photos, - hiddenSections, - }; -} - -/** 保存済みプロジェクト1件(localStorage "misete:projects" の配列要素) */ -export interface SavedProject { - id: string; - name: string; - updatedAt: number; - state: ShareState; -} - -/** Uint8Array → base64url(Unicode安全: TextEncoder のバイト列から btoa → URL安全化) */ -function bytesToBase64Url(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); -} - -/** base64url → Uint8Array(逆変換。パディングを復元してから atob) */ -function base64UrlToBytes(s: string): Uint8Array { - const base64 = s.replace(/-/g, "+").replace(/_/g, "/"); - const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); - const binary = atob(padded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -/** - * 共有状態を base64url 文字列にエンコードする(JSON→UTF8→base64url)。URL の #c= に載せる。 - * 写真(data URI)は1枚で数百KBに達しURL長の限界を超えるため、共有URLからは必ず除外する - * (受け手側は写真なしのLPを見ることになる。UIでその旨を明示している)。 - */ -export function encodeShare(s: ShareState): string { - const json = JSON.stringify({ ...s, a: { ...s.a, photos: [] } }); - const bytes = new TextEncoder().encode(json); - return bytesToBase64Url(bytes); -} - -/** - * base64url 文字列から共有状態を復元する。壊れたデータでも例外を投げず null を返す。 - * t は LP_TEMPLATES に実在するテンプレIDであること、a は LpAnswers の骨格を正しい型・ - * 配列長で満たしていることまで検証する(normalizeLpAnswers)。共有URLは第三者が任意の - * JSONを組み立てて配布できるため、型アサーションだけではプレビュー描画 - * (LpPreview/SwapBoundary)が想定外の形に対して例外を投げクラッシュしうる。 - */ -export function decodeShare(hash: string): ShareState | null { - try { - const bytes = base64UrlToBytes(hash); - const json = new TextDecoder().decode(bytes); - return normalizeShareState(JSON.parse(json)); - } catch { - return null; - } -} - -/** パース済みの値を ShareState として正規化する(不正なら null)。共有URL・保存データ共通。 */ -function normalizeShareState(parsed: unknown): ShareState | null { - if (typeof parsed !== "object" || parsed === null) return null; - const t = (parsed as { t?: unknown }).t; - const a = (parsed as { a?: unknown }).a; - if (typeof t !== "string" || !LP_TEMPLATES.some((tpl) => tpl.id === t)) { - return null; - } - const answers = normalizeLpAnswers(t, a); - return answers ? { t, a: answers } : null; -} - -const PROJECTS_KEY = "misete:projects"; - -/** - * 保存済みプロジェクト一覧を取得する。localStorage不在・壊れたデータは空配列。 - * 各件の state は normalizeShareState を通し、復元できないものは一覧から除外する - * (機能追加前に保存されたプロジェクトも新フィールドを補って開けるようにするため)。 - */ -export function listProjects(): SavedProject[] { - try { - const raw = localStorage.getItem(PROJECTS_KEY); - if (!raw) return []; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - const out: SavedProject[] = []; - for (const item of parsed) { - if (typeof item !== "object" || item === null) continue; - const { id, name, updatedAt, state } = item as Record; - const normalized = normalizeShareState(state); - if ( - typeof id === "string" && - typeof name === "string" && - typeof updatedAt === "number" && - normalized - ) { - out.push({ id, name, updatedAt, state: normalized }); - } - } - return out; - } catch { - return []; - } -} - -/** プロジェクトを保存する(同名があれば上書き、なければ新規追加)。成否を返す。 */ -export function saveProject(name: string, state: ShareState): boolean { - try { - const list = listProjects(); - const existing = list.find((p) => p.name === name); - if (existing) { - existing.state = state; - existing.updatedAt = Date.now(); - } else { - list.push({ - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name, - updatedAt: Date.now(), - state, - }); - } - localStorage.setItem(PROJECTS_KEY, JSON.stringify(list)); - return true; - } catch { - return false; - } -} - -/** プロジェクトを削除する。 */ -export function deleteProject(id: string): void { - try { - const list = listProjects().filter((p) => p.id !== id); - localStorage.setItem(PROJECTS_KEY, JSON.stringify(list)); - } catch { - /* noop */ - } -} - -// ────────────────────────────────────────────────────────────────────────── -// 作業中ドラフトの自動保存(明示的な「保存」を押さずに閉じても失われないように) -// ────────────────────────────────────────────────────────────────────────── - -const DRAFT_KEY = "misete:draft"; - -/** - * 共有URL(#c=…)から開いたセッション用の自動保存キー。 - * 共有された内容は「他人の作業」であり、自分の作業ドラフト(DRAFT_KEY)と同じ枠に - * 書き込むと開いて1文字触るだけで自分の続きが失われる。保存先ごと分けて隔離する。 - */ -const SHARED_DRAFT_KEY = "misete:draft:shared"; - -/** 自動保存の保存先。"own" = 自分の作業、"shared" = 共有URLから開いたセッション。 */ -export type DraftScope = "own" | "shared"; - -const DRAFT_KEYS: Record = { - own: DRAFT_KEY, - shared: SHARED_DRAFT_KEY, -}; - -/** 自動保存の結果。写真ごと保存できたか、容量超過で写真を落としたか、保存できなかったか。 */ -export type DraftSaveResult = "saved" | "saved-without-photos" | "failed"; - -/** - * 作業中の内容を自動保存する。 - * 写真(data URI)を含めると localStorage の容量上限(概ね5MB)に達しうるため、 - * 容量超過で失敗した場合は写真を除いてもう一度だけ試す。呼び出し側は戻り値を見て - * 「写真は自動保存されていない」ことを利用者に伝えられる。 - */ -export function saveDraft( - state: ShareState, - scope: DraftScope = "own" -): DraftSaveResult { - const key = DRAFT_KEYS[scope]; - try { - localStorage.setItem(key, JSON.stringify(state)); - return "saved"; - } catch { - try { - localStorage.setItem( - key, - JSON.stringify({ ...state, a: { ...state.a, photos: [] } }) - ); - return "saved-without-photos"; - } catch { - return "failed"; - } - } -} - -/** 自動保存されたドラフトを読み出す(無い・壊れている場合は null)。 */ -export function loadDraft(scope: DraftScope = "own"): ShareState | null { - try { - const raw = localStorage.getItem(DRAFT_KEYS[scope]); - if (!raw) return null; - return normalizeShareState(JSON.parse(raw)); - } catch { - return null; - } -} - -/** 自動保存されたドラフトを破棄する。 */ -export function clearDraft(scope: DraftScope = "own"): void { - try { - localStorage.removeItem(DRAFT_KEYS[scope]); - } catch { - /* noop */ - } -} diff --git a/src/lp/steps/BuilderHeader.tsx b/src/lp/steps/BuilderHeader.tsx deleted file mode 100644 index 0564c35..0000000 --- a/src/lp/steps/BuilderHeader.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * ウィザード共通のヘッダー(戻る導線・ステップ表示・作業中の名前と自動保存の状態)。 - * 見た目だけを持ち、状態は LpBuilder から受け取る。 - */ -import { ArrowLeft, Check } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; - -export type Step = 1 | 2 | 3 | 4; - -export const STEP_LABELS: Record = { - 1: "業種選択", - 2: "内容入力", - 3: "プレビュー", - 4: "書き出し", -}; - -export default function BuilderHeader({ - step, - onGoTo, - onHome, - title, - draftMessage, -}: { - step: Step; - onGoTo: (s: Step) => void; - onHome: () => void; - /** 作業中のプロジェクト名(未設定なら店名) */ - title: string; - /** 自動保存の状態(無いときは空文字。領域自体は常設して読み上げを届ける) */ - draftMessage: string; -}) { - return ( - /* - * 狭い画面(375px)では折り返さず必ず1段に収める。折り返してヘッダーが高くなると、 - * プレビューのツールバー(sticky top-16 / z-30)がこのヘッダー(z-40)の下に潜り、 - * 「編集に戻る」と表示幅切替が押せなくなるため。作業中の名前は sm 未満で隠す - * (自動保存の読み上げは sr-only で残す)。 - */ -
-
- - - - -
-

- {title || "ミセテLP"} -

- {/* 自動保存の状態は狭い画面でも読み上げに届くよう、視覚的にだけ隠す */} -

- {draftMessage} -

-
-
-
- ); -} - -/** ステップ表示。完了済みのステップへは押して戻れる(現在地は aria-current="step")。 */ -function StepIndicator({ - step, - onGoTo, -}: { - step: Step; - onGoTo: (s: Step) => void; -}) { - return ( -
    - {([1, 2, 3, 4] as Step[]).map((s) => { - const done = s < step; - const current = s === step; - const marker = ( - - {done ? : s} - - ); - const label = ( - - {STEP_LABELS[s]} - - ); - return ( -
  1. - {done ? ( - - ) : ( - - {marker} - {label} - - )} - {s < 4 && ( - - )} -
  2. - ); - })} -
- ); -} diff --git a/src/lp/steps/ExportStep.tsx b/src/lp/steps/ExportStep.tsx deleted file mode 100644 index 91bab1c..0000000 --- a/src/lp/steps/ExportStep.tsx +++ /dev/null @@ -1,503 +0,0 @@ -/** - * ステップ4: 書き出し・共有。 - * 主役は「HTMLをダウンロード」。共有URL・HTMLコピー・プロジェクト保存は副次に置き、 - * 最後に「公開するには?」の手順を畳んで置く。 - * - * props は状態(ExportStatus)・保存パネル(ProjectPanel)・操作(ExportActions)の - * 3つにまとめる(個別に20個以上渡すと呼び出し側の見通しが悪くなるため)。 - */ -import { useRef } from "react"; -import { - ArrowLeft, - Check, - ChevronDown, - Copy, - Download, - ExternalLink, - FolderOpen, - Info, - Loader2, - Save, - Share2, - Sparkles, - Trash2, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import type { LpPlanId } from "../lpPlan"; -import { LP_PLANS, getStripeLink } from "../lpPlan"; -import type { SavedProject } from "../share"; -import type { IndustryTemplate, LpAnswers } from "../types"; - -/** 書き出しまわりの状態 */ -export interface ExportStatus { - plan: LpPlanId; - /** 買い切り版(フル)を持っているか。バッジ非表示・OGP出力の可否になる */ - paid: boolean; - downloading: boolean; - copyingHtml: boolean; - copiedShare: boolean; - /** 完了のお知らせ(aria-live で読み上げる。数秒で消える) */ - notice: string | null; - error: string | null; -} - -/** プロジェクト保存パネルの状態 */ -export interface ProjectPanel { - projects: SavedProject[]; - projectName: string; - saveError: string | null; -} - -/** 書き出しステップから呼び出せる操作 */ -export interface ExportActions { - onProjectNameChange: (v: string) => void; - onDownload: () => void; - onCopyHtml: () => void; - onCopyShareUrl: () => void; - onSaveProject: () => void; - onLoadProject: (p: SavedProject) => void; - onDeleteProject: (id: string) => void; - onSetPlan: (p: LpPlanId) => void; - onBack: () => void; -} - -export default function ExportStep({ - template, - answers, - status, - projectPanel, - actions, -}: { - template: IndustryTemplate; - answers: LpAnswers; - status: ExportStatus; - projectPanel: ProjectPanel; - actions: ExportActions; -}) { - const { paid, downloading, copyingHtml, copiedShare } = status; - const pricingHeadingRef = useRef(null); - - /* - * 「バッジなしで書き出したい」人の導線。別サービス(Component Studio)の料金ページへ - * 飛ばすと、ビルダーの作業から離脱するうえ、そこで買ってもプラン状態は別キー - * (cs:plan / lp:plan)のためバッジは消えない。同じ画面の下にある - * 料金プランへスクロールし、ビルダーから離脱させない。 - * 先にフォーカスを移してから(preventScroll でジャンプを抑えて)滑らかに送る。 - */ - const goToPricing = () => { - pricingHeadingRef.current?.focus({ preventScroll: true }); - pricingHeadingRef.current?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }; - - return ( -
-
-

- 書き出し・共有 -

-

- 完成したLPをHTMLとして持ち帰るか、共有URLで誰にでも見せられます。 -

-
- -
- 書き出し対象:{" "} - {template.name} /{" "} - {answers.shopName} -
- - {status.error && ( -

- {status.error} -

- )} -

- {status.notice ?? ""} -

- - {/* 主役: HTMLダウンロード */} - - - HTMLをダウンロード - - 画像もCSSも埋め込んだ1枚のHTMLファイルです。サーバーに置くだけで公開できます。 - - - - {!paid && ( -
- - - 無料版:書き出しは何回でもできます。フッターに「Made with - ミセテLP」バッジが入ります。 - - -
- )} - - - -
    -
  • - - - 無料 - :書き出し無制限/フッターに「Made with ミセテLP」バッジ - -
  • -
  • - - - フル(¥9,800 買い切り) - :バッジなし/OGP(SNSでの見え方)設定つき - -
  • -
-
-
- - {/* 副次: 共有URL・HTMLコピー・保存 */} - - - 共有・コピー・保存 - - 共有URLは無料・無制限です。HTMLのコピーはダウンロードと同じ成果物(無料版はフッターのバッジ入り)を渡します。 - - - -
- - -
-

- 共有URLに写真は含まれません(URLが長くなりすぎるため)。写真ごと渡すときはHTMLをダウンロードしてください。 -

- -
-

プロジェクト保存

- {projectPanel.saveError && ( -

- {projectPanel.saveError} -

- )} -
- - actions.onProjectNameChange(e.target.value)} - placeholder="プロジェクト名" - className="max-w-xs" - /> - -
- {projectPanel.projects.length > 0 && ( -
    - {projectPanel.projects.map((p) => ( -
  • - {p.name} - - - - -
  • - ))} -
- )} -
-
-
- - - - {/* 「バッジなしで書き出す」の着地点(goToPricing) */} -
-

- 料金プラン -

-

- ※ 決済は未接続です。下のボタンでの切り替えは、機能の違いを試すためのデモです。 -

-
-
- -
- -
-
- ); -} - -/** 公開手段の案内(無料で置ける方法を手順つきで3つ) */ -function PublishGuide() { - return ( -
- - 作ったLPを公開するには? - - -
-

- ダウンロードしたHTMLファイル1つを置くだけで公開できます。無料でできる方法を3つ紹介します。 -

- -
-

① Netlify Drop(いちばん手軽・無料)

-
    -
  1. - - app.netlify.com/drop - - - を開きます。 -
  2. -
  3. - 書き出したHTMLファイルを、ページの枠の中へドラッグ&ドロップします。 -
  4. -
  5. - 数秒で公開URLが発行されます(例: - https://xxxx.netlify.app)。そのURLを名刺やSNSに載せられます。 -
  6. -
-
- -
-

② Cloudflare Pages(無料・直接アップロード)

-
    -
  1. - - Cloudflare Pages - - - に無料登録してログインします。 -
  2. -
  3. - 「プロジェクトを作成」→「直接アップロード」を選びます(Gitの知識は不要です)。 -
  4. -
  5. - HTMLファイルを - - index.html - - という名前にしてアップロードすると公開されます。 -
  6. -
-
- -
-

③ 契約中のレンタルサーバーにFTPで置く

-
    -
  1. - すでにホームページ用のサーバー(さくら・ロリポップ・エックスサーバーなど)を契約している場合はこちら。 -
  2. -
  3. - FTPソフト(FileZilla - など)や管理画面のファイルマネージャーでサーバーに接続します。 -
  4. -
  5. - 公開フォルダ( - - public_html - - や - - www - - など)に - - index.html - - としてアップロードします。 -
  6. -
-
- -

- いずれの方法でも、あとから独自ドメイン(例: - your-shop.com)を設定できます。設定方法は各サービスの「カスタムドメイン」の項目をご覧ください。 -

-
-
- ); -} diff --git a/src/lp/steps/FormStep.tsx b/src/lp/steps/FormStep.tsx deleted file mode 100644 index 7570ffe..0000000 --- a/src/lp/steps/FormStep.tsx +++ /dev/null @@ -1,425 +0,0 @@ -/** - * ステップ2: 質問フォーム。 - * 長い1列を「基本情報 / 紹介文 / 特徴 / 料金プラン / お客様の声 / 写真 / - * 連絡先とCTA / 表示するセクション」のグループに分け、必須項目の充足率を上部に出す。 - * 未入力があっても先へは進める(ブロックしない)。 - */ -import { useMemo } from "react"; -import { ArrowLeft, ArrowRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { FieldGroup, LabeledInput, LabeledTextarea, Note } from "./fields"; -import PhotoUploader from "./PhotoUploader"; -import SectionToggles from "./SectionToggles"; -import type { - Feature, - IndustryTemplate, - LpAnswers, - LpPhoto, - PricePlan, - SectionSlot, - Testimonial, -} from "../types"; - -/** 回答を書き換える操作一式(LpBuilder が状態を持ち、ここは呼ぶだけ) */ -export interface AnswerEditor { - update: (key: K, value: LpAnswers[K]) => void; - updateFeature: (index: 0 | 1 | 2, patch: Partial) => void; - updatePlan: (index: 0 | 1 | 2, patch: Partial) => void; - updateTestimonial: (index: 0 | 1 | 2, patch: Partial) => void; - /** - * 写真だけは圧縮(await)をまたぐ非同期処理から更新されるため、値ではなく - * 関数更新形で受ける(update("photos", …) だと開始時点の配列で上書きしてしまう)。 - */ - updatePhotos: (update: (prev: LpPhoto[]) => LpPhoto[]) => void; -} - -/** - * 入力グループが「どのセクションに出るのか」をテンプレ定義から逆引きする。 - * - * 対応表を templates.ts に持たせると、スワップを足し引きするたびに二重管理になり - * 実態とずれる。ここではスワップそのものを唯一の真実として扱い、 - * 「その値を書き換えたとき出力が変わるセクション」=そのグループが出るセクション - * とみなす(swaps / rawSwaps はいずれも回答の純関数のため副作用なく試せる)。 - */ -function sectionShowing( - t: IndustryTemplate, - probe: (a: LpAnswers) => LpAnswers -): SectionSlot | undefined { - const base = t.defaults; - const changed = probe(base); - return t.sections.find( - (s) => - s.swaps.some((sw) => sw.to(base) !== sw.to(changed)) || - (s.rawSwaps ?? []).some((rs) => rs.toHtml(base) !== rs.toHtml(changed)) - ); -} - -/** 逆引き用の目印(実文言と衝突しない制御文字を末尾に足すだけ) */ -const PROBE_MARK = "\u0000"; - -function probeFeatures(a: LpAnswers): LpAnswers { - const mark = (f: Feature): Feature => ({ - title: f.title + PROBE_MARK, - desc: f.desc + PROBE_MARK, - }); - return { - ...a, - features: [mark(a.features[0]), mark(a.features[1]), mark(a.features[2])], - }; -} - -function probePlans(a: LpAnswers): LpAnswers { - const mark = (p: PricePlan): PricePlan => ({ - name: p.name + PROBE_MARK, - price: p.price + PROBE_MARK, - desc: p.desc + PROBE_MARK, - }); - return { - ...a, - plans: [mark(a.plans[0]), mark(a.plans[1]), mark(a.plans[2])], - }; -} - -function probeTestimonials(a: LpAnswers): LpAnswers { - const mark = (v: Testimonial): Testimonial => ({ - headline: v.headline + PROBE_MARK, - body: v.body + PROBE_MARK, - name: v.name + PROBE_MARK, - meta: v.meta + PROBE_MARK, - }); - return { - ...a, - testimonials: [ - mark(a.testimonials[0]), - mark(a.testimonials[1]), - mark(a.testimonials[2]), - ], - }; -} - -/** 進み具合バーの対象になる必須項目 */ -function requiredFields(a: LpAnswers): { label: string; value: string }[] { - return [ - { label: "店名・屋号", value: a.shopName }, - { label: "地域", value: a.area }, - { label: "キャッチコピー", value: a.tagline }, - { label: "紹介文", value: a.intro }, - { label: "電話番号", value: a.phone }, - { label: "CTAボタンの文言", value: a.ctaLabel }, - { label: "CTAボタンのリンク先", value: a.ctaHref }, - ]; -} - -export default function FormStep({ - template, - answers, - editor, - onBack, - onNext, -}: { - template: IndustryTemplate; - answers: LpAnswers; - editor: AnswerEditor; - onBack: () => void; - onNext: () => void; -}) { - const fields = requiredFields(answers); - const missing = fields.filter((f) => f.value.trim() === ""); - const filled = fields.length - missing.length; - const percent = Math.round((filled / fields.length) * 100); - - // 入力グループとセクションの対応。テンプレが変わらない限り不変なので memo する。 - const groupSections = useMemo( - () => ({ - features: sectionShowing(template, probeFeatures), - plans: sectionShowing(template, probePlans), - testimonials: sectionShowing(template, probeTestimonials), - }), - [template] - ); - const hidden = new Set(answers.hiddenSections); - /** 対応セクションが分かるグループに、セクション名と現在の表示状態を渡す */ - const sectionProps = (slot: SectionSlot | undefined) => - slot - ? { sectionLabel: slot.label, sectionHidden: hidden.has(slot.id) } - : {}; - - // テンプレのデモが実際に表示できる件数だけ「お客様の声」の入力欄を出す - const slots = Math.min(Math.max(template.testimonialSlots, 0), 3); - const testimonialIndexes = Array.from( - { length: slots }, - (_, i) => i as 0 | 1 | 2 - ); - - return ( -
-
-

- LPの内容を入力してください -

-

- あらかじめ業種にあわせたサンプル文言が入っています。そのまま使っても、書き換えてもOKです。 -

-
- - {/* 入力の進み具合 */} -
-
-

入力の進み具合

-

- {filled} / {fields.length} 項目 -

-
-
-
-
- {/* 入力のたびに読み上げが割り込むのを避けるため live region にはしない。 - 常時表示のうえ、状態は上の progressbar が持つ。 */} -

- {missing.length > 0 - ? `未入力の項目があります:${missing - .map((m) => m.label) - .join("・")}(未入力のままでも次へ進めます)` - : "必須項目はすべて入力できています。"} -

-
- - - editor.update("shopName", v)} - hint="LP全体とブラウザのタブ名に使われます。" - /> - editor.update("area", v)} - placeholder="例: 箱根・強羅" - hint="「地域名+業種」で検索されたときに見つかりやすくなります。" - /> -
- editor.update("tagline", v)} - guideLength={30} - hint="一番大きく表示される一文です。お店の魅力をひとことで。" - /> -
-
- - - editor.update("intro", v)} - rows={4} - guideLength={120} - hint="長すぎると読まれません。読み手が知りたいことを先に書きます。" - /> - - - - {answers.features.map((f, i) => ( -
- - 特徴 {i + 1} - - editor.updateFeature(i as 0 | 1 | 2, { title: v })} - guideLength={14} - /> - editor.updateFeature(i as 0 | 1 | 2, { desc: v })} - guideLength={40} - /> -
- ))} -
- - - {answers.plans.map((p, i) => ( -
- - プラン {i + 1} - - editor.updatePlan(i as 0 | 1 | 2, { name: v })} - /> - editor.updatePlan(i as 0 | 1 | 2, { price: v })} - placeholder="例: ¥18,000〜" - /> - editor.updatePlan(i as 0 | 1 | 2, { desc: v })} - /> -
- ))} -
- - - - サンプルの声が入っています。実際にいただいた声に差し替えてから公開してください。 - - {testimonialIndexes.map((i) => { - const t = answers.testimonials[i]; - return ( -
- - お客様の声 {i + 1} - - editor.updateTestimonial(i, { headline: v })} - guideLength={20} - /> - editor.updateTestimonial(i, { body: v })} - rows={3} - guideLength={100} - /> -
- editor.updateTestimonial(i, { name: v })} - placeholder="例: T・K 様" - /> - editor.updateTestimonial(i, { meta: v })} - placeholder="例: 東京都 ・ 連泊にてご利用" - /> -
-
- ); - })} -
- - - - - 写真はLPに直接埋め込まれます(外部サーバー不要)。 - 共有URLには写真は含まれません(URLが長くなりすぎるため)。受け取った相手には写真なしのLPが表示されます。 - - - - - editor.update("phone", v)} - placeholder="0460-00-0000" - /> - editor.update("hours", v)} - placeholder="例: 10:00〜19:00(水曜定休)" - /> -
- editor.update("address", v)} - /> -
- editor.update("ctaLabel", v)} - guideLength={12} - placeholder="例: ご予約はこちら" - /> -
- editor.update("ctaHref", v)} - placeholder="tel:0460-00-0000" - hint="電話は tel: 、メールは mailto: 、LINE や予約サイトは https: ではじめます。" - /> -
-
- - - editor.update("hiddenSections", hidden)} - /> - - -
- - -
-
- ); -} diff --git a/src/lp/steps/IndustryStep.tsx b/src/lp/steps/IndustryStep.tsx deleted file mode 100644 index 784f840..0000000 --- a/src/lp/steps/IndustryStep.tsx +++ /dev/null @@ -1,381 +0,0 @@ -/** - * ステップ1: 業種選択。 - * - * ここは初回訪問者が最初に見る画面のため、テンプレ選択だけでなく - * 価値訴求(何ができて・何が手に入るか)と、再訪者が続きから開ける導線を置く。 - * 業種カードは実 - ); - })} - - - - {/* 保存済みプロジェクトからの再開導線 */} - {projects.length > 0 && ( -
-

- 保存したLPから続ける -

-
    - {projects.map((p) => ( -
  • - - - {p.name} - - - {new Date(p.updatedAt).toLocaleDateString("ja-JP")} 更新 - - - -
  • - ))} -
-
- )} - - {/* 3ステップの説明 */} -
-

- ① 業種を選ぶ → ② 質問に答える → ③ HTMLを書き出す -

-
    - {HOW_IT_WORKS.map((s) => ( -
  1. -

    - - {s.step} - - {s.title} -

    -

    - {s.body} -

    -
  2. - ))} -
-
- - ); -} - -/** 自動保存されたドラフトからの再開(再開 / 新規で始める の2択のみ) */ -function ResumeDraftBanner({ - draft, - onResume, - onDiscard, -}: { - draft: ShareState; - onResume: () => void; - onDiscard: () => void; -}) { - const templateName = - LP_TEMPLATES.find((t) => t.id === draft.t)?.name ?? "テンプレート"; - const shopName = draft.a.shopName.trim() || "無題のLP"; - return ( -
-
-

前回の続きから再開しますか?

-

- 自動保存された内容:{shopName}({templateName}) -

-
-
- - -
-
- ); -} - -/** - * 別の業種テンプレへ切り替える前の確認。 - * 入力済みの内容は切り替え先のサンプル文言に置き換わるため、押し間違いで消さないよう - * ここで一度受け止める(ネイティブの confirm は使わず、意匠を ResumeDraftBanner に揃える)。 - */ -function SwitchTemplateBanner({ - template, - onConfirm, - onCancel, -}: { - template: IndustryTemplate; - onConfirm: () => void; - onCancel: () => void; -}) { - return ( -
-
-

- 「{template.name}」に切り替えますか? -

-

- 入力済みの内容は「{template.name} - 」のサンプル文言に置き換わります(写真はそのまま引き継ぎます)。 -

-
-
- - -
-
- ); -} - -/** - * 業種カードに載せるLPの縮小モックアップ。 - * 画像は一切使わず、テンプレの実配色(photoSection.theme の背景・文字色、accentHex)で - * ナビ帯・見出し・本文・3カラム・フッター帯を積む。文字色は currentColor 経由で - * bg-current + opacity に流すため、テンプレを増やしても指定は1か所で済む。 - */ -function TemplateMockup({ template }: { template: IndustryTemplate }) { - const accent = template.accentHex; - const { bg, text } = template.photoSection.theme; - return ( - - {/* ナビ帯 */} - - - - - - - {/* ヒーロー */} - - - - - - - - - {/* 3カラムの小ブロック */} - - {[0, 1, 2].map((i) => ( - - - - - - ))} - - {/* フッター帯 */} - - - - - - ); -} diff --git a/src/lp/steps/PhotoUploader.tsx b/src/lp/steps/PhotoUploader.tsx deleted file mode 100644 index eab0509..0000000 --- a/src/lp/steps/PhotoUploader.tsx +++ /dev/null @@ -1,274 +0,0 @@ -/** - * 写真アップロード(最大 MAX_PHOTOS 枚)。 - * - * 取り込んだ画像は fileToCompressedDataUrl で縮小・圧縮した data URI として保持し、 - * 書き出しHTMLへそのまま埋め込む(外部サーバー不要)。ドラッグ&ドロップと - * ファイル選択ボタンの両方を用意し、後者は視覚的に隠した を - *