Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 40 additions & 26 deletions docs/LP-BUILDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` だけ公開する

Expand Down Expand Up @@ -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 の読み込みのみで、オフラインでも
フォント以外は崩れない)のダウンロードのみ。ユーザー自身がどこか(自分のサーバー・
Expand Down Expand Up @@ -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
Expand Down
35 changes: 9 additions & 26 deletions src/lp/LpBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Step>(initial.step);
Expand Down Expand Up @@ -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・保存済みプロジェクト
Expand Down Expand Up @@ -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 : "書き出しに失敗しました");
Expand All @@ -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 : "コピーに失敗しました");
Expand Down Expand Up @@ -444,9 +429,7 @@ export default function LpBuilder({ onHome }: LpBuilderProps) {
answers={answers}
status={{
plan,
pro,
remainingExports,
exportBlocked,
paid,
downloading,
copyingHtml,
copiedShare,
Expand Down
28 changes: 14 additions & 14 deletions src/lp/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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("<img src=\"data:image/");
expect(html).toContain('alt="庭園の露天風呂"');
Expand All @@ -246,7 +246,7 @@ describe("書き出しHTML: 写真セクション", () => {
const html = await buildLpDocument(
ryokanTemplate,
answersWith({ photos: [] }),
{ pro: false }
{ paid: false }
);
expect(html).not.toContain("<img src=\"data:image/jpeg");
expect(html).not.toContain(ryokanTemplate.photoSection.heading);
Expand All @@ -258,13 +258,13 @@ describe("書き出しHTML: セクション非表示", () => {
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);
Expand All @@ -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<string, unknown>;
Expand All @@ -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);
Expand All @@ -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(
`<meta name="theme-color" content="${ryokanTemplate.accentHex}" />`
Expand All @@ -338,7 +338,7 @@ describe("書き出しHTML: favicon / theme-color", () => {
const html = await buildLpDocument(
ryokanTemplate,
answersWith({ shopName: "<script>", photos: [] }),
{ pro: false }
{ paid: false }
);
const m = /href="data:image\/svg\+xml,([^"]*)"/.exec(html);
const svg = decodeURIComponent(m![1]);
Expand All @@ -349,7 +349,7 @@ describe("書き出しHTML: favicon / theme-color", () => {
describe("書き出しHTML: OGP(Proのみ)", () => {
it("pro なら og:site_name / og:locale が入る", async () => {
const answers = answersWith({ photos: [photo("庭園の露天風呂")] });
const html = await buildLpDocument(ryokanTemplate, answers, { pro: true });
const html = await buildLpDocument(ryokanTemplate, answers, { paid: true });

expect(html).toContain('property="og:site_name"');
expect(html).toContain('content="ja_JP"');
Expand All @@ -359,7 +359,7 @@ describe("書き出しHTML: OGP(Proのみ)", () => {
const html = await buildLpDocument(
ryokanTemplate,
answersWith({ photos: [photo("庭園の露天風呂")] }),
{ pro: false }
{ paid: false }
);
expect(html).not.toContain("og:site_name");
});
Expand All @@ -373,7 +373,7 @@ describe("書き出しHTML: OGP(Proのみ)", () => {
it("写真があっても og:image / JSON-LDのimage には data URI を重複させない", async () => {
const big = `data:image/jpeg;base64,${"A".repeat(20000)}`;
const answers = answersWith({ photos: [{ dataUrl: big, alt: "露天風呂" }] });
const html = await buildLpDocument(ryokanTemplate, answers, { pro: true });
const html = await buildLpDocument(ryokanTemplate, answers, { paid: true });

expect(html).not.toContain("og:image");
const jsonLd = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(
Expand Down Expand Up @@ -446,7 +446,7 @@ describe("書き出しHTML: 全幅でないセクションの中央寄せ", () =
expect(wrap).toContain(t.photoSection.theme.bg);

const answers = answersWithMarkedTestimonial(t);
const html = await buildLpDocument(t, answers, { pro: false });
const html = await buildLpDocument(t, answers, { paid: false });

const openTag = `<div class="${wrap}">`;
expect(countOf(html, openTag), `${t.id}: ラッパーの数`).toBe(1);
Expand Down
Loading
Loading