From 2f4f945a452fb069dd8471af200539c3c881ddc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 10:25:29 +0000 Subject: [PATCH] =?UTF-8?q?=E3=83=9F=E3=82=BB=E3=83=86LP:=20=E6=9C=88?= =?UTF-8?q?=E9=A1=8D=E3=82=B5=E3=83=96=E3=82=B9=E3=82=AF=E3=82=92=E5=BB=83?= =?UTF-8?q?=E6=AD=A2=E3=81=97=E8=B2=B7=E3=81=84=E5=88=87=E3=82=8A=E3=81=B8?= =?UTF-8?q?=E7=A7=BB=E8=A1=8C=EF=BC=88=E4=BD=9C=E6=A5=AD=E4=B8=AD=E3=81=AE?= =?UTF-8?q?=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=83=9D=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=83=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 料金を 無料 + ¥9,800買い切り の2段に。Pro¥1,480/月・Studio¥4,980/月を廃止 - 無料版の「書き出し月3回」上限を撤廃。LPは書き出して確認し直して再書き出しを 繰り返すもので、価値を感じる前に手を止める設計だった。加えてプラン状態は localStorageにあり上限は編集で外せるため、守れない壁が正直な利用者だけを 止めていた。実効的な壁は書き出しHTMLに焼き込まれるフッターのバッジ1つに集約する - Stripeリンクを VITE_STRIPE_LINK_LP 1本に統一 テスト2862件グリーン(チェックポイント時点) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YXb6DP2sELzRyZkqpe15p4 --- docs/LP-BUILDER.md | 66 ++++++++++------- src/lp/LpBuilder.tsx | 35 +++------ src/lp/export.test.ts | 28 ++++---- src/lp/export.ts | 14 ++-- src/lp/lp.test.tsx | 138 +++++++++++++++++++++--------------- src/lp/lpPlan.ts | 126 ++++++++++++++------------------ src/lp/steps/ExportStep.tsx | 104 +++++++++++---------------- src/lp/swap.test.ts | 65 +++++++---------- src/lp/templates.test.ts | 6 +- src/vite-env.d.ts | 6 +- 10 files changed, 278 insertions(+), 310 deletions(-) diff --git a/docs/LP-BUILDER.md b/docs/LP-BUILDER.md index fbc162a..e3352ff 100644 --- a/docs/LP-BUILDER.md +++ b/docs/LP-BUILDER.md @@ -31,46 +31,58 @@ ## 1. 収益設計(プラン) -| プラン | 月額 | 書き出し | フッターバッジ | OGP | 想定ユーザー | +姉妹プロダクト(コンポーネント集・`src/lib/plan.ts`)と同じく **無料 / 買い切りの2段**。 +作るのは1枚のHTMLファイルで、持ち帰った時点で利用者の手元に残る。毎月こちらが何かを +提供し続ける商品ではないため、継続課金は取らない。 + +| プラン | 価格 | 書き出し | フッターバッジ | OGP | 想定ユーザー | | --- | --- | --- | --- | --- | --- | -| Free | ¥0 | 月3回まで | 強制表示(`Made with ミセテLP`) | なし | まず試したい店主 | -| Pro | ¥1,480/月 | 無制限 | なし | あり | 本気で公開する店主(1名) | -| Studio | ¥4,980/月 | 無制限 | なし | あり | 複数案件を回す制作会社 | +| 無料 | ¥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=...`)は **無料・無制限**。バイラル装置として機能させる -- Free の書き出しバッジはそれ自体が広告(バッジのリンク先は `SITE_URL/lp`) +- 無料版の書き出しバッジはそれ自体が広告(バッジのリンク先は `SITE_URL/lp`) -料金・クォータ・Stripeリンクの定義は `src/lp/lpPlan.ts` が単一の真実。表示文言や上限回数を +料金・Stripeリンクの定義は `src/lp/lpPlan.ts` が単一の真実。表示文言や価格を 変える場合はここだけ直せば UI 全体(`LpBuilder.tsx` の ExportStep 等)に反映される。 --- ## 2. 公開手順(決済リンク→環境変数→ビルド→Access) -### 2-1. Stripe Payment Links を発行する +### 2-1. Stripe Payment Link を発行する -Stripe ダッシュボード → **Payment Links** → Pro(¥1,480/月)・Studio(¥4,980/月)の -サブスクリプション用リンクをそれぞれ1本ずつ発行する。 +Stripe ダッシュボード → **Payment Links** → フル版(¥9,800)の**一括払い**リンクを +1本だけ発行する(買い切りなのでサブスクリプションではない)。 ### 2-2. 環境変数を設定する | 変数名 | 値の例 | 用途 | | --- | --- | --- | -| `VITE_STRIPE_LINK_LP_PRO` | `https://buy.stripe.com/xxxxx` | Pro アップグレードボタンの遷移先 | -| `VITE_STRIPE_LINK_LP_STUDIO` | `https://buy.stripe.com/yyyyy` | Studio アップグレードボタンの遷移先 | -| `VITE_SITE_URL` | `https://misete-lp.pages.dev` | 共有URL・OGP・Freeバッジのリンク先の起点 | +| `VITE_STRIPE_LINK_LP` | `https://buy.stripe.com/xxxxx` | 「フル版を購入」ボタンの遷移先 | +| `VITE_SITE_URL` | `https://misete-lp.pages.dev` | 共有URL・OGP・無料版バッジのリンク先の起点 | -いずれも未設定の場合は `getStripeLink()` が `null` を返し、UI は「デモモード: -プラン体験切替」(`usePlan().setPlan` で見た目だけ切り替える)にフォールバックする。 +未設定の場合は `getStripeLink()` が `null` を返し、UI は「デモモード: +プラン体験切替」(`useLpPlan().setPlan` で見た目だけ切り替える)にフォールバックする。 つまり **環境変数を設定するまでは安全にデモ運用できる**。 ### 2-3. Cloudflare Pages のビルド環境変数に追加する `docs/DEPLOY.md` の手順1でデプロイ済みの Pages プロジェクトに対して: -**Settings → Environment variables** に上記3つを追加(Production、必要なら Preview にも)→ 再デプロイ。 +**Settings → Environment variables** に上記2つを追加(Production、必要なら Preview にも)→ 再デプロイ。 ### 2-4. `docs/DEPLOY.md` の Access 設定からの差分:`/lp` だけ公開する @@ -102,13 +114,16 @@ Cloudflare Access は最も具体的なパスを優先するため、`/`(ス 現状の実装でできること・できないことを正直に書く。営業・広告文言には転用しないこと。 - **プラン状態は `localStorage` のみ**。Stripe Payment Link で決済が完了しても、 - アプリ側の `plan` は自動では `pro` にならない。実決済とアプリのエンタイトルメントを + アプリ側の `plan` は自動では `full` にならない。実決済とアプリのエンタイトルメントを 紐付ける仕組み(Webhook・ユーザーDB)は未実装。現状は Pricing ページと同じ 「※現在は決済未接続のデモです」という正直な注記つきで、本人が体験用に手動切替する だけの状態(`Pricing.tsx` の `demoNote` と同じパターン)。 -- **認証なし**。ログイン機構が無いため、自分のブラウザの `localStorage` を書き換えれば - 誰でも無料で Pro 相当のクォータ・バッジなし書き出しを得られる。クローズドな - デモ/自分運用を前提とした設計であり、不特定多数への商用課金にはまだ耐えない。 +- **認証なし**。ログイン機構が無いため、自分のブラウザの `localStorage`(`lp:plan`)を + 書き換えれば誰でもバッジなし書き出しを得られる。**この構造上、アプリ内のフラグは + 実効的な壁になりえない — 唯一の実効的な壁は、書き出したHTMLに焼き込まれる + フッターのバッジだけ**(公開されたLPに残り、書き換えるには手でHTMLを編集する必要が + ある。そこまでする人は元から払わない)。だからこそ無料版に回数の上限は置かず、 + バッジ1点に絞っている。不特定多数への商用課金にはまだ耐えない。 - **ホスト型公開なし**。生成物は1枚の完全自己完結HTML(CSSはビルド時にコンパイル・ インライン埋め込み済み。外部依存は Google Fonts の読み込みのみで、オフラインでも フォント以外は崩れない)のダウンロードのみ。ユーザー自身がどこか(自分のサーバー・ @@ -181,21 +196,20 @@ Cloudflare Access は最も具体的なパスを優先するため、`/`(ス | マイルストーン | 内容 | | --- | --- | -| **M2** | ホスト型公開URL+独自ドメイン対応(継続課金モデルの本体になる想定)+ 実エンタイトルメント(Stripe Webhook 等で決済とプランを正しく紐付ける)。あわせて `og:image` を公開URL上の実画像に差し替える | +| **M2** | ホスト型公開URL+独自ドメイン対応(フル版の価値を厚くする)+ 実エンタイトルメント(Stripe Webhook 等で決済とプランを正しく紐付ける)。あわせて `og:image` を公開URL上の実画像に差し替える | | **M3** | 業種テンプレートの追加(現在4業種。教室・スクール / 士業 / 工務店 などが候補) | | **M4** | アクセス地図(住所からの地図埋め込み)。iframe は「完全自己完結HTML」の性質を崩すため、既定OFFのオプトイン方式にする | | **v2** | 声(音声)収集モジュール | -M2 が最優先。現状の「1枚HTMLダウンロード」だけでは継続課金の理由が弱く、 -「作ったLPをそのまま `xxx.misete-lp.com` 的なURLで公開し続けられる」ことが -Pro/Studio の本命価値になる。実エンタイトルメント(§3の限界の解消)もこの -タイミングで合わせて入れる。 +M2 が最優先。「作ったLPをそのまま `xxx.misete-lp.com` 的なURLで公開できる」ところまで +面倒を見られると、フル版(¥9,800 買い切り)を選ぶ理由がはっきりする。実エンタイトルメント +(§3の限界の解消)もこのタイミングで合わせて入れる。 --- ## 5. 集客プレイブック(要約) -- **無料版バッジがバイラル装置**: Free の書き出しHTMLに入る +- **無料版バッジがバイラル装置**: 無料版の書き出しHTMLに入る `Made with ミセテLP` バッジ(リンク先 `SITE_URL/lp`)が、公開されたLPを見た 別の店主からの流入経路になる。 - **業種SEO**: 「旅館 LP テンプレート」「サロン LP テンプレート」「クリニック LP diff --git a/src/lp/LpBuilder.tsx b/src/lp/LpBuilder.tsx index fd2e967..5a26238 100644 --- a/src/lp/LpBuilder.tsx +++ b/src/lp/LpBuilder.tsx @@ -10,14 +10,7 @@ import { useEffect, useRef, useState } from "react"; import { LP_TEMPLATES } from "./templates"; import { buildLpDocument, downloadHtml } from "./export"; -import { - FREE_MONTHLY_EXPORT_LIMIT, - SITE_URL, - getMonthExports, - incMonthExports, - isLpPro, - useLpPlan, -} from "./lpPlan"; +import { SITE_URL, isLpPaid, useLpPlan } from "./lpPlan"; import { clearDraft, decodeShare, @@ -118,7 +111,7 @@ export default function LpBuilder({ onHome }: LpBuilderProps) { // 内部で直接使う。Studio 側の usePlan()(src/lib/plan.ts, "cs:plan")とはキーが独立した // 別サービスの状態のため混線しない。 const { plan, setPlan } = useLpPlan(); - const pro = isLpPro(plan); + const paid = isLpPaid(plan); const [initial] = useState(resolveInitialState); const [step, setStep] = useState(initial.step); @@ -213,12 +206,6 @@ export default function LpBuilder({ onHome }: LpBuilderProps) { return () => clearTimeout(timer); }, [copiedShare]); - const remainingExports = Math.max( - 0, - FREE_MONTHLY_EXPORT_LIMIT - getMonthExports() - ); - const exportBlocked = !pro && remainingExports <= 0; - /** * 失われる入力があるか。テンプレ適用時は t.defaults の参照をそのまま state に入れる * ため、参照が別物であれば「利用者が編集した」「ドラフト・共有URL・保存済みプロジェクト @@ -295,13 +282,12 @@ export default function LpBuilder({ onHome }: LpBuilderProps) { }; const handleDownload = async () => { - if (exportBlocked || downloading) return; + if (downloading) return; setExportError(null); setDownloading(true); try { - const html = await buildLpDocument(template, answers, { pro }); + const html = await buildLpDocument(template, answers, { paid }); downloadHtml(`${answers.shopName || "lp"}-lp.html`, html); - if (!pro) incMonthExports(); setNotice("HTMLをダウンロードしました"); } catch (e) { setExportError(e instanceof Error ? e.message : "書き出しに失敗しました"); @@ -310,16 +296,15 @@ export default function LpBuilder({ onHome }: LpBuilderProps) { } }; - // HTMLのコピーはダウンロードと同一の成果物を渡す操作のため、ダウンロードと - // まったく同じ上限判定・回数消費を通す(片方だけ素通しでは唯一の収益ゲートが無効になる)。 + // HTMLのコピーはダウンロードと同一の成果物を渡す操作のため、同じ条件(プラン)で + // 組み立てる。無料版はどちらの経路でもフッターのバッジが入る。 const handleCopyHtml = async () => { - if (exportBlocked || copyingHtml) return; + if (copyingHtml) return; setExportError(null); setCopyingHtml(true); try { - const html = await buildLpDocument(template, answers, { pro }); + const html = await buildLpDocument(template, answers, { paid }); await navigator.clipboard.writeText(html); - if (!pro) incMonthExports(); setNotice("HTMLをコピーしました"); } catch (e) { setExportError(e instanceof Error ? e.message : "コピーに失敗しました"); @@ -444,9 +429,7 @@ export default function LpBuilder({ onHome }: LpBuilderProps) { answers={answers} status={{ plan, - pro, - remainingExports, - exportBlocked, + paid, downloading, copyingHtml, copiedShare, diff --git a/src/lp/export.test.ts b/src/lp/export.test.ts index cc1513a..5da3d0c 100644 --- a/src/lp/export.test.ts +++ b/src/lp/export.test.ts @@ -200,7 +200,7 @@ describe("書き出しHTML: 二重置換の回帰(実テンプレ)", () => { { ...salonTemplate.defaults.plans[2], price: "¥18,500" }, ], }; - const html = await buildLpDocument(salonTemplate, answers, { pro: false }); + const html = await buildLpDocument(salonTemplate, answers, { paid: false }); expect(countOf(html, "¥1,980")).toBe(1); expect(countOf(html, "¥5,800")).toBe(1); @@ -219,7 +219,7 @@ describe("書き出しHTML: 二重置換の回帰(実テンプレ)", () => { { ...salonTemplate.defaults.plans[2], price: "¥12,000" }, ], }; - const html = await buildLpDocument(salonTemplate, answers, { pro: false }); + const html = await buildLpDocument(salonTemplate, answers, { paid: false }); expect(countOf(html, "¥3,000")).toBe(1); expect(countOf(html, "¥4,800")).toBe(1); @@ -233,7 +233,7 @@ describe("書き出しHTML: 写真セクション", () => { const answers = answersWith({ photos: [photo("庭園の露天風呂"), photo("離れの客室", "BBBB")], }); - const html = await buildLpDocument(ryokanTemplate, answers, { pro: false }); + const html = await buildLpDocument(ryokanTemplate, answers, { paid: false }); expect(html).toContain(" { const html = await buildLpDocument( ryokanTemplate, answersWith({ photos: [] }), - { pro: false } + { paid: false } ); expect(html).not.toContain(" { const answers = answersWith({ photos: [] }); const marker = answers.testimonials[0].body; - const full = await buildLpDocument(ryokanTemplate, answers, { pro: false }); + const full = await buildLpDocument(ryokanTemplate, answers, { paid: false }); expect(full).toContain(marker); const hidden = await buildLpDocument( ryokanTemplate, { ...answers, hiddenSections: ["voice"] }, - { pro: false } + { paid: false } ); expect(hidden).not.toContain(marker); expect(hidden.length).toBeLessThan(full.length); @@ -276,7 +276,7 @@ describe("書き出しHTML: セクション非表示", () => { describe("書き出しHTML: JSON-LD 構造化データ", () => { it("application/ld+json として入り、@type がテンプレの schemaType と一致する", async () => { const answers = answersWith({ photos: [photo("庭園の露天風呂")] }); - const html = await buildLpDocument(ryokanTemplate, answers, { pro: false }); + const html = await buildLpDocument(ryokanTemplate, answers, { paid: false }); expect(html).toContain('type="application/ld+json"'); const ld = JSON.parse(extractJsonLd(html)) as Record; @@ -300,7 +300,7 @@ describe("書き出しHTML: JSON-LD 構造化データ", () => { const html = await buildLpDocument( ryokanTemplate, answersWith({ shopName: evil, photos: [] }), - { pro: false } + { paid: false } ); const raw = extractJsonLd(html); @@ -318,7 +318,7 @@ describe("書き出しHTML: JSON-LD 構造化データ", () => { describe("書き出しHTML: favicon / theme-color", () => { it("店名の先頭1文字を描いた SVG favicon と theme-color が入る", async () => { const answers = answersWith({ shopName: "潮騒の宿かもめ", photos: [] }); - const html = await buildLpDocument(ryokanTemplate, answers, { pro: false }); + const html = await buildLpDocument(ryokanTemplate, answers, { paid: false }); expect(html).toContain( `` @@ -338,7 +338,7 @@ describe("書き出しHTML: favicon / theme-color", () => { const html = await buildLpDocument( ryokanTemplate, answersWith({ shopName: "