diff --git a/.gitignore b/.gitignore
index b4cf725..4d746df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ node_modules/
coverage/
test-results/
playwright-report/
+.data/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4447b74..730ff51 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format
## [Unreleased]
+### Added
+
+- A CNY 29 concierge learning-pack offer for validating purchase intent before building automated billing.
+- Consent-based, minimal private lead storage with validation, anti-spam controls, and no automatic charge.
+- Identity-free offer-view and form-open events with a local conversion report.
+- A local paid-sale ledger command so reports distinguish purchase intent from confirmed revenue.
+
## [1.0.0] - 2026-08-11
### Added
diff --git a/README.md b/README.md
index 6ea674d..0576511 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
- 公开网页提取标题、摘要、章节与正文要点。
- 围绕当前主题继续询问用法、示例和误区;百科主题优先依据已取得的来源摘录回答。
- 使用闪卡复习关键概念,并通过一道即时反馈的小测验巩固理解。
+- 用 `¥29/份` 的首批定制学习包验证真实付费意愿,提交只登记意向,不会自动扣款。
- 浮窗可以隐藏为右下角入口并随时恢复。
## 运行
@@ -36,3 +37,28 @@ npm run ci
- 希望跨应用悬浮、置顶、托盘常驻或用全局快捷键唤起:需要桌面客户端,建议使用 Tauri 封装当前界面。
当前轻量版的内置主题、澄清和追问使用本地知识与规则,未知名词通过中文维基百科补充公开资料,不需要模型密钥。若要对任意陌生领域进行更深入的自由问答,可以在 `/api/analyze` 和 `/api/ask` 后接模型服务。
+
+## 首批付费验证
+
+成功分析后会显示定制学习包入口,商品为学习路线、10 张闪卡、5 道带解释的测验和一个实战任务,早期验证价为 `¥29/份`。用户提交主题、目标和联系方式后,由运营者在 24 小时内人工确认范围与付款方式;系统本身不会收款。
+
+购买意向默认保存在未纳入 Git 的 `.data/paid-pack-interests.jsonl`,也可以通过 `QUICKLEARN_LEAD_FILE` 指定私有持久化路径。记录只包含编号、商品版本、价格、主题、目标、联系方式和提交时间,不保存 IP、User-Agent、网页正文、来源摘录或完整分析上下文。运营者应限制文件访问,仅为履约联系使用,并在意向失效或用户要求时删除对应记录。
+
+HTTP 服务只公开界面所需的三个静态文件,不允许通过 URL 读取 `.data`、服务端源码或 Git 元数据。正式部署仍应把 `QUICKLEARN_LEAD_FILE` 和 `QUICKLEARN_EVENT_FILE` 指向有备份且不在公开目录中的私有持久化卷。
+
+商品展示与表单打开事件保存在 `.data/paid-pack-events.jsonl`,每条只有事件名和时间,不使用 Cookie 或身份标识。查看漏斗和待跟进意向:
+
+```bash
+npm run commercial:report
+```
+
+确认实际到账后,使用意向编号记录首笔收入,再查看更新后的转化漏斗:
+
+```bash
+npm run commercial:mark-paid -- QL-XXXXXXXX
+npm run commercial:report
+```
+
+付款记录默认写入 `.data/paid-pack-sales.jsonl`,只包含意向编号、商品版本、金额和到账时间,不重复保存联系方式。
+
+首批用户获取、人工收款、交付和验证阈值见 [docs/FIRST-SALE.md](docs/FIRST-SALE.md)。
diff --git a/app.js b/app.js
index 7ab0f58..4474ece 100644
--- a/app.js
+++ b/app.js
@@ -2,6 +2,7 @@ const state = {
current: null,
lastInput: "",
asking: false,
+ offerSubmitting: false,
study: { mode: null, cardIndex: 0, cardRevealed: false, quizAnswer: null }
};
@@ -23,6 +24,15 @@ function showToast(message) {
showToast.timer = setTimeout(() => toast.classList.remove("show"), 1900);
}
+function recordCommercialEvent(event) {
+ fetch("/api/commercial-event", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ event }),
+ keepalive: true
+ }).catch(() => {});
+}
+
function showView(name) {
$("#startView").hidden = name !== "start";
$("#loadingView").hidden = name !== "loading";
@@ -35,6 +45,7 @@ function showView(name) {
function reset() {
state.current = null;
state.lastInput = "";
+ state.offerSubmitting = false;
resetStudy();
$("#mainInput").value = "";
$("#conversation").innerHTML = "";
@@ -109,9 +120,18 @@ function renderSummary(result) {
${quickQuestions}
${studyActions ? `
${studyActions}
` : ""}
+
+
+ 首批定制
+ 把这个主题做成可执行学习包
+ 学习路线 · 10 张闪卡 · 5 道测验 · 1 个实战任务
+
+
+
`;
showView("conversation");
icons();
+ recordCommercialEvent("offer_view");
}
function resetStudy() {
@@ -150,6 +170,7 @@ function showStudy(mode) {
}
function renderStudyPanel() {
+ $("#offerPanel")?.remove();
$("#studyPanel")?.remove();
if (!state.study.mode) return;
@@ -208,6 +229,76 @@ function renderStudyPanel() {
scrollToBottom();
}
+function showOffer() {
+ if (!state.current) return;
+ recordCommercialEvent("offer_open");
+ resetStudy();
+ $("#studyPanel")?.remove();
+ $("#offerPanel")?.remove();
+ $("#conversation").insertAdjacentHTML("beforeend", `
+
+
+
+
24 小时内人工确认并交付
+
- 聚焦目标的学习路线
- 10 张闪卡与 5 道带解释的测验
- 1 个能真正上手的实战任务
+
+
+ `);
+ icons();
+ scrollToBottom();
+}
+
+async function submitOffer(form) {
+ if (state.offerSubmitting) return;
+ state.offerSubmitting = true;
+ const submit = form.querySelector("button[type='submit']");
+ submit.disabled = true;
+ submit.querySelector("span").textContent = "正在登记";
+ const data = new FormData(form);
+ try {
+ const response = await fetch("/api/paid-pack-interest", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ topic: data.get("topic"),
+ goal: data.get("goal"),
+ contact: data.get("contact"),
+ website: data.get("website"),
+ consent: data.get("consent") === "on"
+ })
+ });
+ const result = await response.json();
+ if (!response.ok) throw new Error(result.error || "暂时无法登记");
+ $("#offerPanel").innerHTML = `
+
+
+
购买意向已登记
+
编号 ${escapeHtml(result.reference)}。我们会通过你留下的联系方式确认范围和付款方式。
+
本次没有自动扣款。
+
+
`;
+ icons();
+ } catch (error) {
+ showToast(error.message);
+ submit.disabled = false;
+ submit.querySelector("span").textContent = "登记购买意向";
+ } finally {
+ state.offerSubmitting = false;
+ scrollToBottom();
+ }
+}
+
async function ask(question) {
const value = question.trim();
if (!value || !state.current || state.asking) return;
@@ -256,6 +347,12 @@ $("#followUpForm").addEventListener("submit", (event) => {
ask($("#followUpInput").value);
});
+document.addEventListener("submit", (event) => {
+ if (!event.target.matches("#offerForm")) return;
+ event.preventDefault();
+ submitOffer(event.target);
+});
+
document.addEventListener("click", (event) => {
const example = event.target.closest("[data-example]");
const clarify = event.target.closest("[data-clarify]");
@@ -266,6 +363,8 @@ document.addEventListener("click", (event) => {
const cardNav = event.target.closest("[data-card-nav]");
const quizOption = event.target.closest("[data-quiz-option]");
const quizReset = event.target.closest("[data-quiz-reset]");
+ const offerOpen = event.target.closest("[data-offer-open]");
+ const offerClose = event.target.closest("[data-offer-close]");
if (example) analyze(example.dataset.example);
if (clarify) analyze(clarify.dataset.clarify);
if (question) ask(question.dataset.question);
@@ -291,6 +390,8 @@ document.addEventListener("click", (event) => {
state.study.quizAnswer = null;
renderStudyPanel();
}
+ if (offerOpen) showOffer();
+ if (offerClose) $("#offerPanel")?.remove();
});
$("#newButton").addEventListener("click", reset);
diff --git a/docs/FIRST-SALE.md b/docs/FIRST-SALE.md
new file mode 100644
index 0000000..ed101ce
--- /dev/null
+++ b/docs/FIRST-SALE.md
@@ -0,0 +1,40 @@
+# 第一笔收入执行手册
+
+## 当前商品
+
+- 用户:正在快速学习 AI 工具或开发技术的中文开发者、独立开发者和转行学习者。
+- 场景:看过零散资料,但不知道先学什么、怎样确认自己已经会用。
+- 商品:围绕一个具体目标制作学习路线、10 张闪卡、5 道带解释的测验和 1 个实战任务。
+- 验证价:`¥29/份`,24 小时内人工确认和交付。
+- 边界:登记意向不等于付款;确认主题、目标和交付时间后,再通过双方认可的方式收款。
+
+## 首批 20 位用户
+
+不要先投广告。每天寻找 5 位正在公开提问 MCP、AI 智能体、React 或 Docker 入门问题的人,优先选择问题具体、近期正在实践的人。可以来自 GitHub Issue、技术社群或自己的真实联系人,但不要批量骚扰,也不要抓取私人联系方式。
+
+首次沟通只发一条短消息:
+
+> 我在做一个“把技术主题变成可执行学习包”的小工具。你刚才提到想学「主题」,我可以先免费给你看 30 秒总结;如果方向对,再用 ¥29 做成针对你目标的路线、练习和测验。链接:你的公开访问地址
+
+对没有回应的人不连续追发。对打开表单的人,先确认三个问题:要解决什么任务、已有基础、希望何时完成。范围确认后再收款。
+
+## 24 小时交付
+
+1. 用 `npm run commercial:report` 查看新意向并联系用户。
+2. 复述用户目标和交付清单,请对方确认。
+3. 通过双方认可的收款方式收取 `¥29`,保留付款凭证,并运行 `npm run commercial:mark-paid -- QL-XXXXXXXX` 记录到账。
+4. 制作一份短而具体的学习包,所有内容围绕用户目标。
+5. 交付后询问两个问题:哪一部分最有用,是否愿意以同样价格再买另一个主题。
+6. 在意向失效、履约结束不再需要,或用户要求时,删除其联系方式记录。
+
+## 七天判断标准
+
+每天记录商品展示、表单打开、购买意向、实际付款和按时交付。前 100 次有效商品展示后再判断:
+
+- 打开率低于 5%:商品名称或结果不够有吸引力,先改定位与文案。
+- 打开率至少 5%,意向率低于 15%:价格、信任或表单阻力有问题。
+- 至少 3 个有效意向但无人付款:先访谈,不接支付系统。
+- 出现 1 笔真实付款:完成第一桶金验证,继续手工卖到 10 单。
+- 完成 10 单且重复需求明显:再建设账号、自动支付和规模化生成。
+
+不要把 GitHub Star、访问量或表单点击当收入。当前阶段唯一的核心结果是真实付款和完成交付。
diff --git a/package.json b/package.json
index fe55368..23735d7 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,12 @@
"scripts": {
"start": "node server.mjs",
"dev": "node --watch server.mjs",
- "lint": "node --check server.mjs && node --check url-fetch.mjs && node --check wikipedia.mjs && node --check app.js",
+ "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",
"test": "node --test test/*.test.mjs",
"smoke": "npm test",
- "ci": "npm run lint && npm run smoke"
+ "ci": "npm run lint && npm run smoke",
+ "commercial:report": "node scripts/commercial-report.mjs",
+ "commercial:mark-paid": "node scripts/commercial-mark-paid.mjs"
},
"engines": {
"node": ">=18"
diff --git a/scripts/commercial-mark-paid.mjs b/scripts/commercial-mark-paid.mjs
new file mode 100644
index 0000000..e927f0a
--- /dev/null
+++ b/scripts/commercial-mark-paid.mjs
@@ -0,0 +1,35 @@
+import { appendFile, chmod, mkdir, readFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+
+const reference = process.argv[2]?.trim().toUpperCase();
+const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(process.cwd(), ".data", "paid-pack-interests.jsonl");
+const saleFile = process.env.QUICKLEARN_SALE_FILE || join(process.cwd(), ".data", "paid-pack-sales.jsonl");
+
+if (!/^QL-[A-F0-9]{8}$/.test(reference || "")) {
+ console.error("用法: npm run commercial:mark-paid -- QL-XXXXXXXX");
+ process.exitCode = 1;
+} else {
+ const readLines = async (path) => {
+ try {
+ return (await readFile(path, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line));
+ } catch (error) {
+ if (error.code === "ENOENT") return [];
+ throw error;
+ }
+ };
+ const [leads, sales] = await Promise.all([readLines(leadFile), readLines(saleFile)]);
+ const lead = leads.find((item) => item.reference === reference);
+ if (!lead) {
+ console.error(`未找到意向编号 ${reference}`);
+ process.exitCode = 1;
+ } else if (sales.some((item) => item.reference === reference)) {
+ console.error(`${reference} 已标记付款`);
+ process.exitCode = 1;
+ } else {
+ const sale = { reference, offer: lead.offer, amountCny: lead.amountCny, paidAt: new Date().toISOString() };
+ await mkdir(dirname(saleFile), { recursive: true, mode: 0o700 });
+ await appendFile(saleFile, `${JSON.stringify(sale)}\n`, { encoding: "utf8", mode: 0o600 });
+ await chmod(saleFile, 0o600);
+ console.log(`已记录 ${reference} 付款 ¥${sale.amountCny}`);
+ }
+}
diff --git a/scripts/commercial-report.mjs b/scripts/commercial-report.mjs
new file mode 100644
index 0000000..43c9ae7
--- /dev/null
+++ b/scripts/commercial-report.mjs
@@ -0,0 +1,37 @@
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+
+const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(process.cwd(), ".data", "paid-pack-interests.jsonl");
+const eventFile = process.env.QUICKLEARN_EVENT_FILE || join(process.cwd(), ".data", "paid-pack-events.jsonl");
+const saleFile = process.env.QUICKLEARN_SALE_FILE || join(process.cwd(), ".data", "paid-pack-sales.jsonl");
+
+async function readJsonLines(path) {
+ try {
+ return (await readFile(path, "utf8"))
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+ } catch (error) {
+ if (error.code === "ENOENT") return [];
+ throw error;
+ }
+}
+
+const [leads, events, sales] = await Promise.all([readJsonLines(leadFile), readJsonLines(eventFile), readJsonLines(saleFile)]);
+const views = events.filter(({ event }) => event === "offer_view").length;
+const opens = events.filter(({ event }) => event === "offer_open").length;
+const revenue = sales.reduce((total, sale) => total + (Number(sale.amountCny) || 0), 0);
+const percent = (value, total) => total ? `${((value / total) * 100).toFixed(1)}%` : "n/a";
+
+console.log(`商品展示: ${views}`);
+console.log(`打开表单: ${opens} (${percent(opens, views)})`);
+console.log(`购买意向: ${leads.length} (${percent(leads.length, opens)})`);
+console.log(`实际付款: ${sales.length} (${percent(sales.length, leads.length)})`);
+console.log(`确认收入: ¥${revenue}`);
+
+if (leads.length) {
+ console.log("\n待跟进意向:");
+ for (const lead of leads) {
+ console.log(`${lead.reference}\t${lead.createdAt}\t${lead.topic}\t${lead.contact}\t${lead.goal}`);
+ }
+}
diff --git a/server.mjs b/server.mjs
index 2cfbee4..74758f4 100644
--- a/server.mjs
+++ b/server.mjs
@@ -1,12 +1,27 @@
import http from "node:http";
-import { readFile } from "node:fs/promises";
-import { extname, join, normalize } from "node:path";
+import { appendFile, chmod, mkdir, readFile } from "node:fs/promises";
+import { dirname, extname, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";
+import { randomUUID } from "node:crypto";
import { fetchPage } from "./url-fetch.mjs";
import { lookupWikipedia } from "./wikipedia.mjs";
const root = fileURLToPath(new URL(".", import.meta.url));
const port = Number(process.env.PORT || 4173);
+const leadFile = process.env.QUICKLEARN_LEAD_FILE || join(root, ".data", "paid-pack-interests.jsonl");
+const commercialEventFile = process.env.QUICKLEARN_EVENT_FILE || join(root, ".data", "paid-pack-events.jsonl");
+let leadRequests = [];
+let commercialEventRequests = [];
+const leadRateWindowMs = 10 * 60 * 1000;
+const leadRateLimit = 30;
+const commercialEvents = new Set(["offer_view", "offer_open"]);
+const publicFiles = new Set(["/index.html", "/app.js", "/styles.css"]);
+
+function cleanLeadText(value) {
+ return typeof value === "string"
+ ? value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim()
+ : "";
+}
const mimeTypes = {
".html": "text/html; charset=utf-8",
@@ -407,12 +422,98 @@ async function handleAsk(req, res) {
}
}
+function withinRateLimit(requests, limit) {
+ const now = Date.now();
+ const recent = requests.filter((time) => now - time < leadRateWindowMs);
+ recent.push(now);
+ return { recent, limited: recent.length > limit };
+}
+
+async function handlePaidPackInterest(req, res) {
+ let body = "";
+ for await (const chunk of req) {
+ body += chunk;
+ if (body.length > 5_000) return sendJson(res, 413, { error: "提交内容过长" });
+ }
+ const leadRate = withinRateLimit(leadRequests, leadRateLimit);
+ leadRequests = leadRate.recent;
+ if (leadRate.limited) return sendJson(res, 429, { error: "提交过于频繁,请稍后再试" });
+
+ try {
+ const { topic, goal, contact, consent, website } = JSON.parse(body || "{}");
+ if (typeof website === "string" && website.trim()) {
+ return sendJson(res, 201, { received: true, charged: false, reference: "QL-RECEIVED" });
+ }
+ if (consent !== true) return sendJson(res, 400, { error: "请先同意我们使用联系方式确认定制需求" });
+ const cleanTopic = cleanLeadText(topic);
+ const cleanGoal = cleanLeadText(goal);
+ const cleanContact = cleanLeadText(contact);
+ if (cleanTopic.length < 1 || cleanTopic.length > 160) {
+ return sendJson(res, 400, { error: "学习主题无效" });
+ }
+ if (cleanGoal.length < 5 || cleanGoal.length > 600) {
+ return sendJson(res, 400, { error: "请用 5 到 600 个字符说明学习目标" });
+ }
+ if (cleanContact.length < 3 || cleanContact.length > 120) {
+ return sendJson(res, 400, { error: "请填写可用的微信号、邮箱或手机号" });
+ }
+
+ const reference = `QL-${randomUUID().split("-")[0].toUpperCase()}`;
+ const record = {
+ reference,
+ offer: "custom-learning-pack-v1",
+ amountCny: 29,
+ topic: cleanTopic,
+ goal: cleanGoal,
+ contact: cleanContact,
+ createdAt: new Date().toISOString()
+ };
+ await mkdir(dirname(leadFile), { recursive: true, mode: 0o700 });
+ await appendFile(leadFile, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
+ await chmod(leadFile, 0o600);
+ sendJson(res, 201, { received: true, charged: false, reference });
+ } catch (error) {
+ if (error instanceof SyntaxError) return sendJson(res, 400, { error: "提交格式无效" });
+ console.error("Unable to store paid-pack interest", error);
+ sendJson(res, 503, { error: "暂时无法登记,请稍后再试" });
+ }
+}
+
+async function handleCommercialEvent(req, res) {
+ let body = "";
+ for await (const chunk of req) {
+ body += chunk;
+ if (body.length > 1_000) return sendJson(res, 413, { error: "事件内容过长" });
+ }
+ const eventRate = withinRateLimit(commercialEventRequests, 2_000);
+ commercialEventRequests = eventRate.recent;
+ if (eventRate.limited) return sendJson(res, 204, {});
+
+ try {
+ const { event } = JSON.parse(body || "{}");
+ if (!commercialEvents.has(event)) return sendJson(res, 400, { error: "未知事件" });
+ await mkdir(dirname(commercialEventFile), { recursive: true, mode: 0o700 });
+ await appendFile(commercialEventFile, `${JSON.stringify({ event, createdAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 });
+ await chmod(commercialEventFile, 0o600);
+ sendJson(res, 204, {});
+ } catch (error) {
+ if (!(error instanceof SyntaxError)) console.error("Unable to store commercial event", error);
+ sendJson(res, error instanceof SyntaxError ? 400 : 503, { error: "事件记录失败" });
+ }
+}
+
const server = http.createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/api/analyze") return handleAnalyze(req, res);
if (req.method === "POST" && req.url === "/api/ask") return handleAsk(req, res);
+ if (req.method === "POST" && req.url === "/api/paid-pack-interest") return handlePaidPackInterest(req, res);
+ if (req.method === "POST" && req.url === "/api/commercial-event") return handleCommercialEvent(req, res);
if (req.method !== "GET") return sendJson(res, 405, { error: "Method not allowed" });
const requestPath = req.url === "/" ? "/index.html" : req.url.split("?")[0];
+ if (!publicFiles.has(requestPath)) {
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+ return res.end("Not found");
+ }
const safePath = normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
const filePath = join(root, safePath);
if (!filePath.startsWith(root)) return sendJson(res, 403, { error: "Forbidden" });
diff --git a/styles.css b/styles.css
index dcae8f5..daaa01c 100644
--- a/styles.css
+++ b/styles.css
@@ -16,7 +16,7 @@
* { box-sizing: border-box; }
html, body { min-width: 100%; min-height: 100%; margin: 0; }
body { min-height: 100vh; overflow: hidden; color: var(--ink); background: var(--page); }
-button, input { font: inherit; letter-spacing: 0; }
+button, input, textarea { font: inherit; letter-spacing: 0; }
button { color: inherit; cursor: pointer; }
[hidden] { display: none !important; }
@@ -159,6 +159,42 @@ input::placeholder { color: #9ca29b; }
.quiz-feedback.is-wrong strong { color: #ad5546; }
.quiz-feedback p { margin: 5px 0 0; overflow-wrap: anywhere; color: #606660; font-size: 9px; line-height: 1.6; }
.study-action:focus-visible, .study-icon-button:focus-visible, .reveal-button:focus-visible, .quiz-option:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
+.paid-offer { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px; border: 1px solid #cbd1c7; border-left: 3px solid var(--lime); border-radius: 7px; background: #f7f9f4; }
+.paid-offer-copy { min-width: 0; }
+.paid-offer-copy > span { display: block; margin-bottom: 4px; color: #697266; font-size: 8px; font-weight: 700; }
+.paid-offer-copy > strong { display: block; overflow-wrap: anywhere; font-size: 11px; line-height: 1.45; }
+.paid-offer-copy > small { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--muted); font-size: 8px; line-height: 1.5; }
+.paid-offer > button { min-width: 106px; min-height: 37px; display: grid; grid-template-columns: minmax(0, 1fr) auto 13px; align-items: center; gap: 5px; padding: 7px 9px; border: 0; border-radius: 5px; color: #20251e; background: var(--lime); font-size: 9px; }
+.paid-offer > button:hover { background: #d8f86b; }
+.paid-offer > button strong { font-size: 10px; }
+.paid-offer > button svg { width: 12px; }
+.offer-panel { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: white; }
+.offer-details { padding: 15px 15px 13px; border-bottom: 1px solid var(--line); background: #f7f9f4; }
+.offer-details > strong { font-size: 12px; }
+.offer-details ul { display: grid; gap: 5px; margin: 9px 0 0; padding: 0; list-style: none; }
+.offer-details li { position: relative; padding-left: 13px; color: #60675f; font-size: 9px; line-height: 1.5; }
+.offer-details li::before { content: ""; position: absolute; top: 5px; left: 0; width: 5px; height: 5px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 1px #9eae64; }
+.offer-form { display: grid; gap: 11px; padding: 15px; }
+.offer-form > label:not(.offer-consent):not(.offer-honeypot) { display: grid; gap: 5px; color: #525852; font-size: 9px; font-weight: 600; }
+.offer-form input:not([type="checkbox"]), .offer-form textarea { min-width: 0; width: 100%; padding: 9px 10px; border: 1px solid #d5d9d2; border-radius: 5px; outline: 0; color: var(--ink); background: white; font-size: 10px; font-weight: 400; }
+.offer-form input:not([type="checkbox"]) { height: 36px; }
+.offer-form textarea { min-height: 72px; resize: vertical; line-height: 1.55; }
+.offer-form input:focus, .offer-form textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(99,114,223,.1); }
+.offer-consent { display: grid; grid-template-columns: 15px minmax(0, 1fr); align-items: start; gap: 7px; color: #666d65; font-size: 8px; line-height: 1.5; }
+.offer-consent input { width: 14px; height: 14px; margin: 0; accent-color: var(--blue); }
+.offer-honeypot { display: none !important; }
+.offer-submit { min-height: 38px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 12px; border: 0; border-radius: 5px; color: #20251e; background: var(--lime); font-size: 10px; }
+.offer-submit:hover:not(:disabled) { background: #d8f86b; }
+.offer-submit:disabled { cursor: wait; opacity: .65; }
+.offer-form > p { margin: -3px 0 0; color: #81877f; font-size: 8px; line-height: 1.5; text-align: center; }
+.offer-success { min-height: 260px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 28px 22px; text-align: center; }
+.offer-success > span { width: 38px; height: 38px; display: grid; place-items: center; margin-bottom: 14px; border-radius: 50%; color: #2c693c; background: #e8f6eb; }
+.offer-success > span svg { width: 18px; }
+.offer-success > strong { font-size: 14px; }
+.offer-success > p { margin: 8px 0 0; color: #5f665f; font-size: 10px; line-height: 1.65; }
+.offer-success > small { margin-top: 7px; color: #858b84; font-size: 8px; }
+.offer-success > button { height: 31px; margin-top: 18px; padding: 0 12px; border: 1px solid var(--line); border-radius: 5px; background: white; font-size: 9px; }
+.paid-offer > button:focus-visible, .offer-submit:focus-visible, .offer-success > button:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
.user-message { justify-self: end; max-width: 82%; padding: 9px 12px; border-radius: 7px 7px 2px 7px; color: white; background: var(--ink); font-size: 11px; line-height: 1.55; }
.follow-answer { display: grid; gap: 8px; padding: 13px 14px; border: 1px solid var(--line); border-radius: 7px; background: white; }
.follow-answer strong { font-size: 12px; }
@@ -187,6 +223,8 @@ input::placeholder { color: #9ca29b; }
.learning-widget { inset: 0; width: 100%; height: 100%; border: 0; border-radius: 0; }
.start-view { padding-inline: 22px; }
.conversation { padding-inline: 15px; }
+ .paid-offer { grid-template-columns: 1fr; }
+ .paid-offer > button { width: 100%; }
.launcher { right: 16px; bottom: 16px; }
}
diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs
index 02e07cd..f49983f 100644
--- a/test/smoke.test.mjs
+++ b/test/smoke.test.mjs
@@ -1,10 +1,16 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { spawn } from "node:child_process";
+import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
const port = 43000 + (process.pid % 1000);
const baseUrl = `http://127.0.0.1:${port}`;
let server;
+let testDirectory;
+let leadFile;
+let commercialEventFile;
async function waitForServer() {
for (let attempt = 0; attempt < 40; attempt += 1) {
@@ -31,16 +37,20 @@ async function post(path, body) {
}
before(async () => {
+ testDirectory = await mkdtemp(join(tmpdir(), "quicklearn-smoke-"));
+ leadFile = join(testDirectory, "paid-pack-interests.jsonl");
+ commercialEventFile = join(testDirectory, "paid-pack-events.jsonl");
server = spawn(process.execPath, ["server.mjs"], {
cwd: new URL("..", import.meta.url),
- env: { ...process.env, PORT: String(port) },
+ env: { ...process.env, PORT: String(port), QUICKLEARN_LEAD_FILE: leadFile, QUICKLEARN_EVENT_FILE: commercialEventFile },
stdio: ["ignore", "pipe", "pipe"]
});
await waitForServer();
});
-after(() => {
+after(async () => {
if (server && !server.killed) server.kill("SIGTERM");
+ await rm(testDirectory, { recursive: true, force: true });
});
test("serves the compact learning window", async () => {
@@ -78,6 +88,63 @@ test("serves the flashcard and quiz interactions", async () => {
assert.match(styles, /\.quiz-option\.is-correct/);
});
+test("records a minimal paid-pack interest without charging", async () => {
+ const response = await fetch(`${baseUrl}/api/paid-pack-interest`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "User-Agent": "smoke-secret-agent" },
+ body: JSON.stringify({
+ topic: "MCP",
+ goal: "两天内独立做出一个只读 MCP 工具",
+ contact: "learner@example.com\u001b[31m",
+ consent: true,
+ context: { source: { excerpts: ["must not be stored"] } }
+ })
+ });
+ assert.equal(response.status, 201);
+ const result = await response.json();
+ assert.equal(result.received, true);
+ assert.equal(result.charged, false);
+ assert.match(result.reference, /^QL-[A-F0-9]{8}$/);
+
+ const records = (await readFile(leadFile, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
+ assert.equal(records.length, 1);
+ assert.deepEqual(Object.keys(records[0]).sort(), ["amountCny", "contact", "createdAt", "goal", "offer", "reference", "topic"]);
+ assert.equal(records[0].amountCny, 29);
+ assert.equal(records[0].contact, "learner@example.com [31m");
+ assert.doesNotMatch(JSON.stringify(records[0]), /must not be stored|smoke-secret-agent|127\.0\.0\.1/);
+ assert.equal((await stat(leadFile)).mode & 0o777, 0o600);
+
+ for (const path of ["/.data/paid-pack-interests.jsonl", "/server.mjs", "/.git/config"]) {
+ assert.equal((await fetch(`${baseUrl}${path}`)).status, 404, path);
+ }
+});
+
+test("records aggregate commercial events without identity data", async () => {
+ for (const event of ["offer_view", "offer_open"]) {
+ const response = await fetch(`${baseUrl}/api/commercial-event`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "User-Agent": "must-not-be-stored" },
+ body: JSON.stringify({ event, topic: "must-not-be-stored" })
+ });
+ assert.equal(response.status, 204);
+ }
+ const events = (await readFile(commercialEventFile, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
+ assert.deepEqual(events.map(({ event }) => event), ["offer_view", "offer_open"]);
+ assert.deepEqual(Object.keys(events[0]).sort(), ["createdAt", "event"]);
+ assert.doesNotMatch(JSON.stringify(events), /must-not-be-stored/);
+ assert.equal((await stat(commercialEventFile)).mode & 0o777, 0o600);
+});
+
+test("rejects paid-pack interest without explicit consent", async () => {
+ const response = await fetch(`${baseUrl}/api/paid-pack-interest`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ topic: "React", goal: "完成一个实际项目", contact: "test@example.com", consent: false })
+ });
+ assert.equal(response.status, 400);
+ assert.match((await response.json()).error, /同意/);
+});
+
test("asks for clarification when a term is ambiguous", async () => {
const result = await post("/api/analyze", { input: "苹果" });
assert.equal(result.needsClarification, true);