Skip to content

Commit fcf58e1

Browse files
committed
feat: validate paid custom learning packs
1 parent dfd9442 commit fcf58e1

11 files changed

Lines changed: 462 additions & 7 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ node_modules/
77
coverage/
88
test-results/
99
playwright-report/
10+
.data/

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- A CNY 29 concierge learning-pack offer for validating purchase intent before building automated billing.
10+
- Consent-based, minimal private lead storage with validation, anti-spam controls, and no automatic charge.
11+
- Identity-free offer-view and form-open events with a local conversion report.
12+
- A local paid-sale ledger command so reports distinguish purchase intent from confirmed revenue.
13+
714
## [1.0.0] - 2026-08-11
815

916
### Added

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- 公开网页提取标题、摘要、章节与正文要点。
1010
- 围绕当前主题继续询问用法、示例和误区;百科主题优先依据已取得的来源摘录回答。
1111
- 使用闪卡复习关键概念,并通过一道即时反馈的小测验巩固理解。
12+
-`¥29/份` 的首批定制学习包验证真实付费意愿,提交只登记意向,不会自动扣款。
1213
- 浮窗可以隐藏为右下角入口并随时恢复。
1314

1415
## 运行
@@ -36,3 +37,28 @@ npm run ci
3637
- 希望跨应用悬浮、置顶、托盘常驻或用全局快捷键唤起:需要桌面客户端,建议使用 Tauri 封装当前界面。
3738

3839
当前轻量版的内置主题、澄清和追问使用本地知识与规则,未知名词通过中文维基百科补充公开资料,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze``/api/ask` 后接模型服务。
40+
41+
## 首批付费验证
42+
43+
成功分析后会显示定制学习包入口,商品为学习路线、10 张闪卡、5 道带解释的测验和一个实战任务,早期验证价为 `¥29/份`。用户提交主题、目标和联系方式后,由运营者在 24 小时内人工确认范围与付款方式;系统本身不会收款。
44+
45+
购买意向默认保存在未纳入 Git 的 `.data/paid-pack-interests.jsonl`,也可以通过 `QUICKLEARN_LEAD_FILE` 指定私有持久化路径。记录只包含编号、商品版本、价格、主题、目标、联系方式和提交时间,不保存 IP、User-Agent、网页正文、来源摘录或完整分析上下文。运营者应限制文件访问,仅为履约联系使用,并在意向失效或用户要求时删除对应记录。
46+
47+
HTTP 服务只公开界面所需的三个静态文件,不允许通过 URL 读取 `.data`、服务端源码或 Git 元数据。正式部署仍应把 `QUICKLEARN_LEAD_FILE``QUICKLEARN_EVENT_FILE` 指向有备份且不在公开目录中的私有持久化卷。
48+
49+
商品展示与表单打开事件保存在 `.data/paid-pack-events.jsonl`,每条只有事件名和时间,不使用 Cookie 或身份标识。查看漏斗和待跟进意向:
50+
51+
```bash
52+
npm run commercial:report
53+
```
54+
55+
确认实际到账后,使用意向编号记录首笔收入,再查看更新后的转化漏斗:
56+
57+
```bash
58+
npm run commercial:mark-paid -- QL-XXXXXXXX
59+
npm run commercial:report
60+
```
61+
62+
付款记录默认写入 `.data/paid-pack-sales.jsonl`,只包含意向编号、商品版本、金额和到账时间,不重复保存联系方式。
63+
64+
首批用户获取、人工收款、交付和验证阈值见 [docs/FIRST-SALE.md](docs/FIRST-SALE.md)

app.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const state = {
22
current: null,
33
lastInput: "",
44
asking: false,
5+
offerSubmitting: false,
56
study: { mode: null, cardIndex: 0, cardRevealed: false, quizAnswer: null }
67
};
78

@@ -23,6 +24,15 @@ function showToast(message) {
2324
showToast.timer = setTimeout(() => toast.classList.remove("show"), 1900);
2425
}
2526

27+
function recordCommercialEvent(event) {
28+
fetch("/api/commercial-event", {
29+
method: "POST",
30+
headers: { "Content-Type": "application/json" },
31+
body: JSON.stringify({ event }),
32+
keepalive: true
33+
}).catch(() => {});
34+
}
35+
2636
function showView(name) {
2737
$("#startView").hidden = name !== "start";
2838
$("#loadingView").hidden = name !== "loading";
@@ -35,6 +45,7 @@ function showView(name) {
3545
function reset() {
3646
state.current = null;
3747
state.lastInput = "";
48+
state.offerSubmitting = false;
3849
resetStudy();
3950
$("#mainInput").value = "";
4051
$("#conversation").innerHTML = "";
@@ -109,9 +120,18 @@ function renderSummary(result) {
109120
${quickQuestions}
110121
</div>
111122
${studyActions ? `<div class="study-actions">${studyActions}</div>` : ""}
123+
<section class="paid-offer" aria-label="定制学习包">
124+
<div class="paid-offer-copy">
125+
<span>首批定制</span>
126+
<strong>把这个主题做成可执行学习包</strong>
127+
<small>学习路线 · 10 张闪卡 · 5 道测验 · 1 个实战任务</small>
128+
</div>
129+
<button data-offer-open aria-label="定制学习包,29 元每份"><span>定制学习包</span><strong>¥29</strong><i data-lucide="arrow-up-right"></i></button>
130+
</section>
112131
</article>`;
113132
showView("conversation");
114133
icons();
134+
recordCommercialEvent("offer_view");
115135
}
116136

117137
function resetStudy() {
@@ -150,6 +170,7 @@ function showStudy(mode) {
150170
}
151171

152172
function renderStudyPanel() {
173+
$("#offerPanel")?.remove();
153174
$("#studyPanel")?.remove();
154175
if (!state.study.mode) return;
155176

@@ -208,6 +229,76 @@ function renderStudyPanel() {
208229
scrollToBottom();
209230
}
210231

232+
function showOffer() {
233+
if (!state.current) return;
234+
recordCommercialEvent("offer_open");
235+
resetStudy();
236+
$("#studyPanel")?.remove();
237+
$("#offerPanel")?.remove();
238+
$("#conversation").insertAdjacentHTML("beforeend", `
239+
<section class="offer-panel" id="offerPanel">
240+
<header class="study-panel-head">
241+
<div><i data-lucide="package-check"></i><strong>定制学习包</strong><small>¥29 / 份</small></div>
242+
<button class="study-icon-button" data-offer-close aria-label="关闭定制学习包" title="关闭"><i data-lucide="x"></i></button>
243+
</header>
244+
<div class="offer-details">
245+
<strong>24 小时内人工确认并交付</strong>
246+
<ul><li>聚焦目标的学习路线</li><li>10 张闪卡与 5 道带解释的测验</li><li>1 个能真正上手的实战任务</li></ul>
247+
</div>
248+
<form class="offer-form" id="offerForm">
249+
<label>学习主题<input name="topic" value="${escapeHtml(state.current.title)}" maxlength="160" required /></label>
250+
<label>想达到什么目标?<textarea name="goal" minlength="5" maxlength="600" required placeholder="例如:两天内理解 MCP,并独立做出一个只读工具"></textarea></label>
251+
<label>联系方式<input name="contact" maxlength="120" required autocomplete="email" placeholder="微信号、邮箱或手机号" /></label>
252+
<label class="offer-honeypot" aria-hidden="true">网站<input name="website" tabindex="-1" autocomplete="off" /></label>
253+
<label class="offer-consent"><input name="consent" type="checkbox" required /><span>同意仅用此联系方式确认本次需求和付款方式,不用于无关营销</span></label>
254+
<button class="offer-submit" type="submit"><span>登记购买意向</span><strong>¥29</strong></button>
255+
<p>提交只登记意向,不会自动扣款;确认需求后再决定是否付款。</p>
256+
</form>
257+
</section>`);
258+
icons();
259+
scrollToBottom();
260+
}
261+
262+
async function submitOffer(form) {
263+
if (state.offerSubmitting) return;
264+
state.offerSubmitting = true;
265+
const submit = form.querySelector("button[type='submit']");
266+
submit.disabled = true;
267+
submit.querySelector("span").textContent = "正在登记";
268+
const data = new FormData(form);
269+
try {
270+
const response = await fetch("/api/paid-pack-interest", {
271+
method: "POST",
272+
headers: { "Content-Type": "application/json" },
273+
body: JSON.stringify({
274+
topic: data.get("topic"),
275+
goal: data.get("goal"),
276+
contact: data.get("contact"),
277+
website: data.get("website"),
278+
consent: data.get("consent") === "on"
279+
})
280+
});
281+
const result = await response.json();
282+
if (!response.ok) throw new Error(result.error || "暂时无法登记");
283+
$("#offerPanel").innerHTML = `
284+
<div class="offer-success" role="status">
285+
<span><i data-lucide="check"></i></span>
286+
<strong>购买意向已登记</strong>
287+
<p>编号 ${escapeHtml(result.reference)}。我们会通过你留下的联系方式确认范围和付款方式。</p>
288+
<small>本次没有自动扣款。</small>
289+
<button data-offer-close>返回学习</button>
290+
</div>`;
291+
icons();
292+
} catch (error) {
293+
showToast(error.message);
294+
submit.disabled = false;
295+
submit.querySelector("span").textContent = "登记购买意向";
296+
} finally {
297+
state.offerSubmitting = false;
298+
scrollToBottom();
299+
}
300+
}
301+
211302
async function ask(question) {
212303
const value = question.trim();
213304
if (!value || !state.current || state.asking) return;
@@ -256,6 +347,12 @@ $("#followUpForm").addEventListener("submit", (event) => {
256347
ask($("#followUpInput").value);
257348
});
258349

350+
document.addEventListener("submit", (event) => {
351+
if (!event.target.matches("#offerForm")) return;
352+
event.preventDefault();
353+
submitOffer(event.target);
354+
});
355+
259356
document.addEventListener("click", (event) => {
260357
const example = event.target.closest("[data-example]");
261358
const clarify = event.target.closest("[data-clarify]");
@@ -266,6 +363,8 @@ document.addEventListener("click", (event) => {
266363
const cardNav = event.target.closest("[data-card-nav]");
267364
const quizOption = event.target.closest("[data-quiz-option]");
268365
const quizReset = event.target.closest("[data-quiz-reset]");
366+
const offerOpen = event.target.closest("[data-offer-open]");
367+
const offerClose = event.target.closest("[data-offer-close]");
269368
if (example) analyze(example.dataset.example);
270369
if (clarify) analyze(clarify.dataset.clarify);
271370
if (question) ask(question.dataset.question);
@@ -291,6 +390,8 @@ document.addEventListener("click", (event) => {
291390
state.study.quizAnswer = null;
292391
renderStudyPanel();
293392
}
393+
if (offerOpen) showOffer();
394+
if (offerClose) $("#offerPanel")?.remove();
294395
});
295396

296397
$("#newButton").addEventListener("click", reset);

docs/FIRST-SALE.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# 第一笔收入执行手册
2+
3+
## 当前商品
4+
5+
- 用户:正在快速学习 AI 工具或开发技术的中文开发者、独立开发者和转行学习者。
6+
- 场景:看过零散资料,但不知道先学什么、怎样确认自己已经会用。
7+
- 商品:围绕一个具体目标制作学习路线、10 张闪卡、5 道带解释的测验和 1 个实战任务。
8+
- 验证价:`¥29/份`,24 小时内人工确认和交付。
9+
- 边界:登记意向不等于付款;确认主题、目标和交付时间后,再通过双方认可的方式收款。
10+
11+
## 首批 20 位用户
12+
13+
不要先投广告。每天寻找 5 位正在公开提问 MCP、AI 智能体、React 或 Docker 入门问题的人,优先选择问题具体、近期正在实践的人。可以来自 GitHub Issue、技术社群或自己的真实联系人,但不要批量骚扰,也不要抓取私人联系方式。
14+
15+
首次沟通只发一条短消息:
16+
17+
> 我在做一个“把技术主题变成可执行学习包”的小工具。你刚才提到想学「主题」,我可以先免费给你看 30 秒总结;如果方向对,再用 ¥29 做成针对你目标的路线、练习和测验。链接:你的公开访问地址
18+
19+
对没有回应的人不连续追发。对打开表单的人,先确认三个问题:要解决什么任务、已有基础、希望何时完成。范围确认后再收款。
20+
21+
## 24 小时交付
22+
23+
1.`npm run commercial:report` 查看新意向并联系用户。
24+
2. 复述用户目标和交付清单,请对方确认。
25+
3. 通过双方认可的收款方式收取 `¥29`,保留付款凭证,并运行 `npm run commercial:mark-paid -- QL-XXXXXXXX` 记录到账。
26+
4. 制作一份短而具体的学习包,所有内容围绕用户目标。
27+
5. 交付后询问两个问题:哪一部分最有用,是否愿意以同样价格再买另一个主题。
28+
6. 在意向失效、履约结束不再需要,或用户要求时,删除其联系方式记录。
29+
30+
## 七天判断标准
31+
32+
每天记录商品展示、表单打开、购买意向、实际付款和按时交付。前 100 次有效商品展示后再判断:
33+
34+
- 打开率低于 5%:商品名称或结果不够有吸引力,先改定位与文案。
35+
- 打开率至少 5%,意向率低于 15%:价格、信任或表单阻力有问题。
36+
- 至少 3 个有效意向但无人付款:先访谈,不接支付系统。
37+
- 出现 1 笔真实付款:完成第一桶金验证,继续手工卖到 10 单。
38+
- 完成 10 单且重复需求明显:再建设账号、自动支付和规模化生成。
39+
40+
不要把 GitHub Star、访问量或表单点击当收入。当前阶段唯一的核心结果是真实付款和完成交付。

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
"scripts": {
77
"start": "node server.mjs",
88
"dev": "node --watch server.mjs",
9-
"lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js",
9+
"lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js && node --check scripts/commercial-report.mjs && node --check scripts/commercial-mark-paid.mjs",
1010
"test": "node --test test/*.test.mjs",
1111
"smoke": "npm test",
12-
"ci": "npm run lint && npm run smoke"
12+
"ci": "npm run lint && npm run smoke",
13+
"commercial:report": "node scripts/commercial-report.mjs",
14+
"commercial:mark-paid": "node scripts/commercial-mark-paid.mjs"
1315
},
1416
"engines": {
1517
"node": ">=18"

scripts/commercial-mark-paid.mjs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { appendFile, chmod, mkdir, readFile } from "node:fs/promises";
2+
import { dirname, join } from "node:path";
3+
4+
const reference = process.argv[2]?.trim().toUpperCase();
5+
const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(process.cwd(), ".data", "paid-pack-interests.jsonl");
6+
const saleFile = process.env.QUICKLEARN_SALE_FILE || join(process.cwd(), ".data", "paid-pack-sales.jsonl");
7+
8+
if (!/^QL-[A-F0-9]{8}$/.test(reference || "")) {
9+
console.error("用法: npm run commercial:mark-paid -- QL-XXXXXXXX");
10+
process.exitCode = 1;
11+
} else {
12+
const readLines = async (path) => {
13+
try {
14+
return (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line));
15+
} catch (error) {
16+
if (error.code === "ENOENT") return [];
17+
throw error;
18+
}
19+
};
20+
const [leads, sales] = await Promise.all([readLines(leadFile), readLines(saleFile)]);
21+
const lead = leads.find((item) => item.reference === reference);
22+
if (!lead) {
23+
console.error(`未找到意向编号 ${reference}`);
24+
process.exitCode = 1;
25+
} else if (sales.some((item) => item.reference === reference)) {
26+
console.error(`${reference} 已标记付款`);
27+
process.exitCode = 1;
28+
} else {
29+
const sale = { reference, offer: lead.offer, amountCny: lead.amountCny, paidAt: new Date().toISOString() };
30+
await mkdir(dirname(saleFile), { recursive: true, mode: 0o700 });
31+
await appendFile(saleFile, `${JSON.stringify(sale)}\n`, { encoding: "utf8", mode: 0o600 });
32+
await chmod(saleFile, 0o600);
33+
console.log(`已记录 ${reference} 付款 ¥${sale.amountCny}`);
34+
}
35+
}

scripts/commercial-report.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { readFile } from "node:fs/promises";
2+
import { join } from "node:path";
3+
4+
const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(process.cwd(), ".data", "paid-pack-interests.jsonl");
5+
const eventFile = process.env.QUICKLEARN_EVENT_FILE || join(process.cwd(), ".data", "paid-pack-events.jsonl");
6+
const saleFile = process.env.QUICKLEARN_SALE_FILE || join(process.cwd(), ".data", "paid-pack-sales.jsonl");
7+
8+
async function readJsonLines(path) {
9+
try {
10+
return (await readFile(path, "utf8"))
11+
.split("\n")
12+
.filter(Boolean)
13+
.map((line) => JSON.parse(line));
14+
} catch (error) {
15+
if (error.code === "ENOENT") return [];
16+
throw error;
17+
}
18+
}
19+
20+
const [leads, events, sales] = await Promise.all([readJsonLines(leadFile), readJsonLines(eventFile), readJsonLines(saleFile)]);
21+
const views = events.filter(({ event }) => event === "offer_view").length;
22+
const opens = events.filter(({ event }) => event === "offer_open").length;
23+
const revenue = sales.reduce((total, sale) => total + (Number(sale.amountCny) || 0), 0);
24+
const percent = (value, total) => total ? `${((value / total) * 100).toFixed(1)}%` : "n/a";
25+
26+
console.log(`商品展示: ${views}`);
27+
console.log(`打开表单: ${opens} (${percent(opens, views)})`);
28+
console.log(`购买意向: ${leads.length} (${percent(leads.length, opens)})`);
29+
console.log(`实际付款: ${sales.length} (${percent(sales.length, leads.length)})`);
30+
console.log(`确认收入: ¥${revenue}`);
31+
32+
if (leads.length) {
33+
console.log("\n待跟进意向:");
34+
for (const lead of leads) {
35+
console.log(`${lead.reference}\t${lead.createdAt}\t${lead.topic}\t${lead.contact}\t${lead.goal}`);
36+
}
37+
}

0 commit comments

Comments
 (0)