/* ════════════════════════════════════════════════════════════════
Cây Bồ Đề — Mobile App (bright saffron + white, Bchannel style)
No emoji — clean SVG line icons.
"APP LÀM THỬ FULL": mọi chức năng thao tác được, state thật qua
module store (pub/sub + localStorage 'sdtd_app_v1').
Persona: Trần Minh An (pháp danh Minh An).
════════════════════════════════════════════════════════════════ */
const { useState, useEffect, useRef } = React;
const C = {
bg: '#fffdf5', // near-white content
cream: '#fcf2d2',
card: '#ffffff',
card2: '#fff8e8',
line: '#f0e2bd',
saffron:'#f59000',
saffronD:'#e07700',
gold: '#fbc117',
amber: '#ffbe3d',
brown: '#97561a',
ink: '#3d2509',
soft: '#7a521f',
faint: '#a9824a',
white: '#ffffff',
};
const F = "'Manrope', system-ui, sans-serif";
// ── SVG icon set (stroke, currentColor) ──
function Icon({ name, size = 22, fill = false, color = 'currentColor', sw = 1.9 }) {
const p = {
home: ,
award: ,
plus: ,
temple: ,
person: ,
leaf: ,
bell: ,
check: ,
shield: ,
qr: ,
chevron: ,
book: ,
hands: ,
connect: ,
clock: ,
pin: ,
sparkle: ,
calendar: ,
gift: ,
meditate: ,
drop: ,
star: ,
send: ,
chat: ,
camera: ,
lock: ,
edit: ,
refresh: ,
}[name];
return (
{p}
);
}
const Leaf = ({ s = 15, color = C.saffron }) => (
);
function Card({ children, style, onClick }) {
return
{children}
;
}
// header used inside white content
function Header({ title, sub, back }) {
return (
{back &&
‹ Trang chủ }
{sub}
{title}
);
}
function Avatar({ size = 38, icon = 'meditate' }) {
return (
);
}
function Ring({ pct, size = 92, label, value }) {
const r = size / 2 - 7, c = 2 * Math.PI * r;
return (
);
}
// rounded saffron top banner behind status bar / title
function TopBanner({ children, h = 150 }) {
return (
{children}
);
}
// ════════ BODHI TREE GROWTH ════════
// Visualizes accumulated Lá Bồ Đề as a growing bodhi tree.
// Ngưỡng cấp (cấp 1..6): 0 / 200 / 600 / 1200 / 2000 / 3000 Lá.
const TREE_STAGES = [
{ min: 0, key: 'Hạt giống', leaves: 0, trunk: 0, desc: 'Hành trình bắt đầu từ một hạt giống thiện tâm. Hãy gieo thiện hành đầu tiên.' },
{ min: 200, key: 'Nảy mầm', leaves: 3, trunk: 28, desc: 'Mầm xanh đã nhú. Mỗi thiện hành là một giọt nước lành nuôi cây lớn lên.' },
{ min: 600, key: 'Cây non', leaves: 9, trunk: 54, desc: 'Cây non vươn mình. Bạn đang đi đúng hướng trên con đường tỉnh thức.' },
{ min: 1200, key: 'Cây vững', leaves: 18, trunk: 78, desc: 'Thân cây đã vững. Tán lá ngày một sum suê theo công đức của bạn.' },
// trunk cấp 5–6 giữ ≤ 86 để tán còn headroom trong viewport vuông (xem clamp
// spread trong BodhiTree) — trunk 96/110 cũ đẩy tâm tán lên sát mép, lá bị cắt.
{ min: 2000, key: 'Tỏa bóng', leaves: 30, trunk: 80, desc: 'Cây bắt đầu tỏa bóng mát, che chở và lan tỏa thiện lành đến mọi người.' },
{ min: 3000, key: 'Cây Bồ Đề', leaves: 46, trunk: 86, desc: 'Cây Bồ Đề sum suê — biểu tượng cho hành trình bền bỉ vun bồi từ bi và trí tuệ.' },
];
function treeStage(leaf) {
let s = 0;
for (let i = 0; i < TREE_STAGES.length; i++) if (leaf >= TREE_STAGES[i].min) s = i;
return s;
}
function BodhiTree({ leaf = 0, size = 150 }) {
const si = treeStage(leaf);
const st = TREE_STAGES[si];
const cx = size / 2, groundY = size * 0.86;
// deterministic leaf positions around a canopy center
const canopyY = groundY - st.trunk * (size / 150) - size * 0.06;
// clamp độ xòe tán theo headroom còn lại phía trên tâm tán — thân càng cao
// (cấp 5–6) tán càng phải gọn lại, để lá (kể cả bán kính hình lá) không văng
// khỏi viewport (ly < 0).
const spread = Math.min(1, (canopyY - size * 0.06) / (size * 0.30));
const leaves = [];
const n = st.leaves;
for (let i = 0; i < n; i++) {
const ang = (i * 137.5) * Math.PI / 180; // golden-angle phyllotaxis
const rad = ((size * 0.05) + Math.sqrt(i / Math.max(n, 1)) * size * 0.28) * spread;
const lx = cx + Math.cos(ang) * rad;
const ly = canopyY + Math.sin(ang) * rad * 0.82;
const rot = (ang * 180 / Math.PI) + 90;
leaves.push({ lx, ly, rot, s: 0.42 + (i % 3) * 0.06 });
}
const sc = size / 150;
return (
{/* ground */}
{si === 0 ? (
// seed
) : (
{/* trunk */}
{/* a couple of branches for bigger stages */}
{si >= 3 && }
{si >= 3 && }
{/* leaves — each a bodhi-leaf heart-drop shape */}
{leaves.map((lf, i) => (
))}
)}
);
}
/* ════════════════════════════════════════════════════════════════
STATE CORE — module store (pub/sub) + persist localStorage
Key: 'sdtd_app_v1'. SSR/private-mode an toàn (try/catch → memory).
Số chuẩn toàn dự án (KHÔNG phá): 1.248 Lá · 310/500 (62%) ·
#214 (GHIM — demo) · Top-3 Gala: Minh Tuệ 4.820 / Diệu Tâm 4.510 /
Nguyên Hạnh 4.205 · Top 8%.
════════════════════════════════════════════════════════════════ */
const STORE_KEY = 'sdtd_app_v1';
const MONTH_CAP = 500; // trần Lá/tháng — cơ chế Phòng Ác
// Giai đoạn Khởi Động (T3–T9/2026) — đếm ngược tính live từ ngày hiện tại, không hardcode
const PHASE_START = new Date(2026, 2, 1); // 01/3/2026
const PHASE_END = new Date(2026, 8, 30); // đóng sổ 30/9/2026
// Mốc dự án Cầu Nối: mỗi mốc mang Lá riêng (15–50) — đồng bộ roadmap Webapp.html
const uid = () => 'x' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
const fmt = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
const nowTime = () => { const d = new Date(); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); };
const todayKey = () => { const d = new Date(); return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); };
// 11 loại thiện hành (khớp 11 lĩnh vực hệ thống) — bảng điểm 15–80 Lá/loại.
// Số Lá ĐỒNG BỘ với các record đã chốt toàn dự án:
// khóa thiền/khóa tu 50 (seed h1, notification n1, Webapp 'Khóa thiền 3 ngày · +50')
// giảng pháp 40 (seed h3, Webapp) · hiến máu 30 (seed h4, TEMPLE_INFO Chùa Hương Sắc, Webapp)
// xuất bản ISBN 80 (Webapp 'Xuất bản tập thơ ISBN · +80') — các loại còn lại scale ×3 tương ứng.
const DEED_TYPES = [
['hands', 'Thiện nguyện cộng đồng', '≥ 10 người hưởng lợi', 60],
['meditate', 'Khóa thiền / khóa tu', '≥ 3 ngày tập trung', 50],
['book', 'Xuất bản tác phẩm', 'Có mã ISBN', 80],
['person', 'Giảng pháp công cộng', 'Buổi giảng đại chúng', 40],
['drop', 'Hiến máu nhân đạo', 'Mỗi lần hiến hợp lệ', 30],
['star', 'Giáo dục — lớp học 0 đồng', 'Mỗi lớp/khóa hoàn thành', 45],
['leaf', 'Bảo vệ môi trường', 'Trồng cây / dọn rác có minh chứng', 30],
['sparkle', 'Bảo tồn di sản', 'Tham gia trùng tu / số hóa', 45],
['connect', 'Truyền thông Phật pháp số', 'Nội dung được duyệt đăng', 30],
['gift', 'Công quả tại chùa', 'Mỗi buổi công quả', 15],
['temple', 'Hộ trì sự kiện Phật giáo', 'Mỗi sự kiện phục vụ', 30],
];
// Top-3 chuẩn (đồng bộ Portal & Webapp) — không đổi thứ tự/số liệu
const GALA_TOP = [['Thầy Minh Tuệ', '4.820', 1], ['Sư cô Diệu Tâm', '4.510', 2], ['Cư sĩ Nguyên Hạnh', '4.205', 3]];
const GALA_REST = [['Cư sĩ Thiện Đức', '3.980'], ['Ni sư Hương Trang', '3.760'], ['Nhóm Hương Sắc Bồ Đề', '3.540']];
const TEMPLE_INFO = {
'Chùa Quán Sứ': { place: 'Hà Nội', dist: '0.4 km', founded: '1934', address: '73 Quán Sứ, Hoàn Kiếm', members: '24.100',
desc: 'Trụ sở Trung ương Giáo hội Phật giáo Việt Nam — trung tâm Phật giáo lớn của miền Bắc.',
acts: [
// việc con (đợt tuyển trợ giảng tháng 7) của dự án Cầu Nối p1 'Lớp tiếng Anh
// miễn phí cho trẻ em' — đặt tên riêng để 2 thực thể không bị đọc nhầm là trùng lặp
{ t: 'Trợ giảng lớp tiếng Anh — đợt T7', req: 'Tình nguyện viên dạy / trợ giảng (T7, CN)', due: '30/06/2026', leaf: 80, type: 'Giáo dục', ic: 'book', status: 'open' },
{ t: 'Khóa tu Bát Quan Trai', req: 'Đăng ký tham dự trọn một ngày', due: '15/06/2026', leaf: 50, type: 'Tu học', ic: 'meditate', status: 'open' },
{ t: 'Phát quà Trung thu cho trẻ vùng cao', req: 'Đóng góp / đồng hành chuyến đi', due: '20/09/2026', leaf: 40, type: 'Thiện nguyện', ic: 'hands', status: 'soon' },
] },
'Chùa Trấn Quốc': { place: 'Tây Hồ', dist: '1.2 km', founded: '541', address: 'Thanh Niên, Tây Hồ', members: '18.700',
desc: 'Ngôi chùa cổ nhất Hà Nội, hơn 1.500 năm tuổi bên Hồ Tây.',
acts: [
{ t: 'Khóa thiền cuối tuần', req: 'Tham dự ≥ 2 buổi', due: '10/06/2026', leaf: 45, type: 'Tu học', ic: 'meditate', status: 'open' },
{ t: 'Trùng tu vườn tháp cổ', req: 'Đóng góp công đức tu bổ', due: '31/07/2026', leaf: 70, type: 'Di sản', ic: 'sparkle', status: 'soon' },
] },
'Chùa Hương Sắc': { place: 'Hà Đông', dist: '3.8 km', founded: '1998', address: 'Phú Lương, Hà Đông', members: '9.400',
desc: 'Trung tâm sinh hoạt Phật pháp và truyền thông Hương Sắc Bồ Đề.',
acts: [
{ t: 'Sản xuất nội dung Phật pháp số', req: 'Cộng tác viết / dựng video', due: '25/06/2026', leaf: 90, type: 'Truyền thông', ic: 'connect', status: 'open' },
{ t: 'Bếp chay 0 đồng cuối tháng', req: 'Tình nguyện viên nấu / phục vụ', due: '28/06/2026', leaf: 60, type: 'Thiện nguyện', ic: 'hands', status: 'open' },
{ t: 'Hiến máu nhân đạo', req: 'Mỗi lần hiến hợp lệ', due: '12/07/2026', leaf: 30, type: 'Cộng đồng', ic: 'drop', status: 'soon' },
] },
};
// ── Thiện Tri Thức: flows + keyword matching (mượn matchFlow của thien-tri-thuc.html)
// Trích dẫn dùng PLACEHOLDER (chờ Ban Văn hóa kiểm định) — TUYỆT ĐỐI không chèn bản dịch kinh thật.
const CHAT_FLOWS = [
{ q: 'Con đang giận anh trai…', ctx: 'sân hận · gia đình', cites: [
{ src: 'Kinh Pháp Cú · Phẩm Song Yếu · câu 5', tags: ['Sân hận', 'Gia đình', 'Buông xả'] },
{ src: 'Kinh Pháp Cú · Phẩm Phẫn Nộ · câu 223', tags: ['Sân hận', 'Từ bi'] } ] },
{ q: 'Mất việc khiến con hoang mang', ctx: 'công việc · vô thường', cites: [
{ src: 'Kinh Pháp Cú · Phẩm Vô Thường · câu 277', tags: ['Vô thường', 'Công việc'] },
{ src: 'Kinh Pháp Cú · Phẩm An Lạc · câu 204', tags: ['An lạc', 'Tri túc'] } ] },
{ q: 'Làm sao buông được lo âu?', ctx: 'lo âu · điều tâm', cites: [
{ src: 'Kinh Pháp Cú · Phẩm Tâm · câu 35', tags: ['Lo âu', 'Điều tâm'] },
{ src: 'Kinh Pháp Cú · Phẩm Hiền Trí · câu 81', tags: ['Vững chãi', 'Buông xả'] } ] },
{ q: 'Con vừa mất người thân, lòng trống rỗng quá', ctx: 'mất mát · vô thường', cites: [
{ src: 'Kinh Pháp Cú · Phẩm Già · câu 148', tags: ['Vô thường', 'Khổ đau'] },
{ src: 'Kinh Từ Bi (Metta Sutta) · Tiểu Bộ · kệ 7–8', tags: ['Từ bi', 'Thương yêu'] } ] },
{ q: 'Vợ chồng con cãi nhau mãi, mệt mỏi quá', ctx: 'gia đình · hòa hợp', cites: [
{ src: 'Kinh Phước Đức (Mangala Sutta) · Tiểu Bộ · kệ 4', tags: ['Gia đình', 'Hòa hợp'] },
{ src: 'Kinh Pháp Cú · Phẩm Phẫn Nộ · câu 223', tags: ['Từ bi', 'Nhẫn nhịn'] } ] },
];
const CHAT_FALLBACK = { ctx: 'đời thường', cites: [
{ src: 'Kinh Pháp Cú · Phẩm Tâm · câu 35', tags: ['Điều tâm', 'Tỉnh thức'] },
{ src: 'Kinh Pháp Cú · Phẩm Vô Thường · câu 277', tags: ['Vô thường', 'Quán chiếu'] } ] };
const CHAT_KEYWORDS = [
{ re: /giận|sân|tức|hận|ghét/i, flow: 0 },
{ re: /việc|nghề|thất nghiệp|sa thải|công ty|sự nghiệp/i, flow: 1 },
{ re: /lo|sợ|bất an|căng thẳng|áp lực|stress/i, flow: 2 },
{ re: /mất|tang|qua đời|ra đi|người thân|trống rỗng/i, flow: 3 },
{ re: /vợ|chồng|cãi|hôn nhân|ly hôn|gia đình/i, flow: 4 },
];
function matchChatFlow(q) {
const exact = CHAT_FLOWS.findIndex(f => f.q === q);
if (exact >= 0) return exact;
for (const k of CHAT_KEYWORDS) if (k.re.test(q)) return k.flow;
return -1; // không khớp → bộ trích dẫn tổng quát
}
function seedState() {
return {
v: 3, // BUMP mỗi khi đổi schema (v3: seed p3 joined:true khớp Webapp; v2: milestones mang leaf riêng)
name: 'Trần Minh An',
dharma: 'Minh An',
leaves: 1248,
monthLeaves: 310,
rank: 214, // GHIM — không đổi khi tương tác (demo)
history: [
{ id: 'h1', t: 'Khóa thiền 3 ngày — chùa Hoằng Pháp', date: 'Hôm qua', leaf: 50, ic: 'meditate' },
{ id: 'h2', t: 'Phát cơm từ thiện · 40 suất', date: '12/05', leaf: 100, ic: 'hands' },
{ id: 'h3', t: 'Giảng pháp công cộng tại đạo tràng', date: '08/05', leaf: 40, ic: 'book' },
{ id: 'h4', t: 'Hiến máu nhân đạo', date: '02/05', leaf: 30, ic: 'drop' },
{ id: 'h5', t: 'Check-in Chùa Hoằng Pháp', date: '28/05', leaf: 2, ic: 'temple' },
],
notifications: [
{ id: 'n1', ic: 'check', tone: 'ok', t: 'Thiện hành được chùa xác thực', d: 'Khóa thiền 3 ngày · +50 Lá Bồ Đề', time: '09:41', day: 'Hôm nay', unread: true },
{ id: 'n2', ic: 'connect', tone: 'gold', t: 'Dự án mới phù hợp với bạn', d: 'Lớp tiếng Anh miễn phí — Chùa Quán Sứ', time: '08:02', day: 'Hôm nay', unread: true },
{ id: 'n3', ic: 'chat', tone: 'gold', t: 'Thiện Tri Thức', d: '2 trích dẫn mới cho câu hỏi bạn lưu.', time: '07:15', day: 'Hôm nay', unread: true },
{ id: 'n4', ic: 'award', tone: 'gold', t: 'Bạn thăng hạng lên #214', d: 'Tăng 12 bậc trong bảng vinh danh tháng', time: '19:20', day: 'Hôm qua', unread: false },
{ id: 'n5', ic: 'calendar', tone: 'gold', t: 'Sắp tới hạn nộp hồ sơ', d: 'Giai đoạn Khởi Động đóng sổ 30/9/2026', time: '14:05', day: 'Hôm qua', unread: false },
{ id: 'n6', ic: 'gift', tone: 'gold', t: 'Nhà bảo trợ mới đồng hành', d: 'Mảnh Đồng Vàng được trao cho dự án bạn theo dõi', time: '11:30', day: 'Hôm qua', unread: false },
],
deeds: [], // {id, typeIdx, t, leaf, ic, approver, evidence, status:'pending'|'approved', granted?, time}
checkins: [{ id: 'ck-seed', name: 'Chùa Hoằng Pháp', place: 'TP.HCM', date: '28/05/2026 · 08:10', day: '2026-5-28' }],
connectTasks: [], // việc nhận từ check-in chùa (thay MY_CONNECT cũ)
// Mốc & Lá/mốc PORT NGUYÊN VĂN từ roadmap Webapp.html (cùng persona Minh An):
// p1 đã tham gia + 2 mốc đầu đã duyệt, p2 đã tham gia + 1 mốc đã duyệt,
// p3 đã tham gia, mốc 1 đang mở — Webapp: mốc 1 'current', chưa mốc nào approved
// — 2 demo khớp trạng thái.
projects: [
{ id: 'p1', t: 'Lớp tiếng Anh miễn phí cho trẻ em', org: 'Chùa Quán Sứ · Hà Nội', cat: 'Giáo dục', need: 'Cần 4 tình nguyện viên', ic: 'book', joined: true, completed: false,
milestones: [
{ t: 'Khảo sát & lập danh sách lớp', leaf: 20, done: true },
{ t: 'Soạn giáo trình 8 buổi', leaf: 30, done: true },
{ t: 'Giảng dạy 8 buổi học', leaf: 40, done: false },
{ t: 'Tổng kết & báo cáo kết quả', leaf: 30, done: false },
] },
{ id: 'p2', t: 'Bếp ăn 0 đồng tại bệnh viện', org: 'Đạo Tràng Từ Tâm · Đà Nẵng', cat: 'Xã hội', need: 'Cần 15 tình nguyện viên', ic: 'hands', joined: true, completed: false,
milestones: [
{ t: 'Tập huấn an toàn thực phẩm', leaf: 15, done: true },
{ t: 'Phục vụ 4 ca nấu ăn', leaf: 40, done: false },
{ t: 'Vận động thêm nhà hảo tâm', leaf: 25, done: false },
] },
{ id: 'p3', t: 'Khóa tu mùa hè cho thanh thiếu niên', org: 'Chùa Ba Vàng · Quảng Ninh', cat: 'Phật pháp', need: 'Cần 8 điều phối viên', ic: 'meditate', joined: true, completed: false,
milestones: [
{ t: 'Chuẩn bị nội dung sinh hoạt', leaf: 20, done: false },
{ t: 'Trợ giảng 3 ngày khóa tu', leaf: 50, done: false },
{ t: 'Thu hoạch & chia sẻ', leaf: 20, done: false },
] },
],
votes: [186, 142, 117],
voted: null,
chatLog: [{ id: 'c-seed', q: 'Con đang giận anh trai…', flow: 0 }],
};
}
function loadState() {
try {
const raw = localStorage.getItem(STORE_KEY);
// Merge đè lên defaults mới: field thêm sau (cùng version) vẫn có giá trị seed,
// tránh undefined.filter/map cho khách quay lại. Đổi schema sâu hơn → bump v.
if (raw) { const s = JSON.parse(raw); if (s && s.v === 3 && s.notifications) return Object.assign({}, seedState(), s); }
} catch (e) {}
return seedState();
}
let STATE = loadState();
const LISTENERS = new Set();
function saveState() { try { localStorage.setItem(STORE_KEY, JSON.stringify(STATE)); } catch (e) {} }
function setState(patch) {
STATE = Object.assign({}, STATE, patch);
saveState();
LISTENERS.forEach(fn => { try { fn(STATE); } catch (e) {} });
}
// hook — mọi screen subscribe store; SSR-safe (useEffect không chạy khi renderToString)
function useStore() {
const [s, setS] = useState(STATE);
useEffect(() => {
const fn = ns => setS(ns);
LISTENERS.add(fn);
setS(STATE); // sync nếu store đổi giữa render & effect
return () => LISTENERS.delete(fn);
}, []);
return s;
}
// derived helpers
const unreadCount = s => s.notifications.filter(n => n.unread).length;
const approvedDeeds = s => s.deeds.filter(d => d.status === 'approved').length;
const anyMilestoneDone = s => s.projects.some(p => p.milestones.some(m => m.done));
// DEED_TIMERS: CHỦ ĐÍCH để module-level, KHÔNG clear khi screen unmount — đây là
// sai khác có cân nhắc so với câu chữ đặc tả mục 3 ("clear timeout khi unmount"):
// • Ý ĐỒ AN TOÀN của đặc tả vẫn được thỏa: timer chỉ mutate module store (không setState
// trực tiếp); component unmount đã gỡ listener trong useStore → không có
// setState-after-unmount.
// • Nếu clear theo nghĩa đen khi unmount: deed vừa gửi rồi chuyển tab trong 4s sẽ kẹt
// 'pending' vĩnh viễn trong phiên — bug thật. Deed pending còn được re-arm khi reload
// (xem rescheduler cuối phần actions). KHÔNG clear nếu không re-arm lại.
const DEED_TIMERS = {};
const actions = {
notify(ic, tone, t, d) {
setState({ notifications: [{ id: uid(), ic, tone, t, d, time: nowTime(), day: 'Hôm nay', unread: true }, ...STATE.notifications] });
},
markRead(id) {
setState({ notifications: STATE.notifications.map(n => n.id === id ? Object.assign({}, n, { unread: false }) : n) });
},
markAllRead() {
setState({ notifications: STATE.notifications.map(n => n.unread ? Object.assign({}, n, { unread: false }) : n) });
},
// cộng Lá có trần tháng (Phòng Ác) — điểm demo đắt giá
addLeaves(n, label, ic) {
const room = Math.max(0, MONTH_CAP - STATE.monthLeaves);
const granted = Math.min(n, room);
const clipped = granted < n;
setState({
leaves: STATE.leaves + granted,
monthLeaves: STATE.monthLeaves + granted,
history: granted > 0 ? [{ id: uid(), t: label, date: 'Hôm nay', leaf: granted, ic: ic || 'leaf' }, ...STATE.history] : STATE.history,
});
if (clipped) actions.notify('shield', 'gold', 'Đã chạm trần 500 Lá/tháng — cơ chế Phòng Ác',
granted > 0
? `Chỉ cộng +${granted}/${n} Lá cho “${label}”. Phần vượt trần không tích lũy — giữ thiện hành thuần vì tâm.`
: `“${label}” không được cộng Lá trong tháng này. Trần tháng giúp thiện hành thuần vì tâm, không chạy đua điểm.`);
return { granted, clipped };
},
checkIn(name) {
const day = todayKey();
if (STATE.checkins.some(c => c.name === name && c.day === day)) return { ok: false, dup: true };
const info = TEMPLE_INFO[name] || { place: '' };
setState({ checkins: [{ id: uid(), name, place: info.place, date: 'Hôm nay · ' + nowTime(), day }, ...STATE.checkins] });
const r = actions.addLeaves(2, 'Check-in ' + name, 'temple');
actions.notify('temple', 'ok', 'Check-in thành công', name + (r.granted > 0 ? ` · +${r.granted} Lá Bồ Đề` : ' · đã chạm trần tháng'));
return { ok: true, granted: r.granted };
},
addConnectTask(name, a) {
if (STATE.connectTasks.some(x => x.t === a.t)) return;
setState({ connectTasks: [{ id: uid(), t: a.t, org: name + ' · ' + (TEMPLE_INFO[name] || {}).place, cat: a.type, leaf: a.leaf, need: a.req, due: a.due, ic: a.ic, fromTemple: true }, ...STATE.connectTasks] });
},
removeConnectTask(t) {
setState({ connectTasks: STATE.connectTasks.filter(x => x.t !== t) });
},
submitDeed({ typeIdx, approver, evidence }) {
const ty = DEED_TYPES[typeIdx];
const deed = { id: uid(), typeIdx, t: ty[1], leaf: ty[3], ic: ty[0], approver, evidence: !!evidence, status: 'pending', time: nowTime() };
setState({ deeds: [deed, ...STATE.deeds] });
actions.scheduleApproval(deed.id);
return deed.id;
},
scheduleApproval(id) { // auto-demo: 4s sau Trụ trì xác thực
if (DEED_TIMERS[id]) return;
DEED_TIMERS[id] = setTimeout(() => { delete DEED_TIMERS[id]; actions.approveDeed(id); }, 4000);
},
approveDeed(id) {
const d = STATE.deeds.find(x => x.id === id);
if (!d || d.status !== 'pending') return;
const r = actions.addLeaves(d.leaf, d.t + ' — đã xác thực', d.ic);
setState({ deeds: STATE.deeds.map(x => x.id === id ? Object.assign({}, x, { status: 'approved', granted: r.granted }) : x) });
// granted=0 (trần tháng đã cạn): KHÔNG hiện '+0 Lá' — addLeaves đã tự bắn
// notification trần Phòng Ác riêng, ở đây chỉ báo kết quả xác thực.
actions.notify('check', 'ok', 'Trụ trì đã xác thực ✓',
r.granted === 0
? `${d.t} · Trụ trì đã xác thực — đã chạm trần tháng, Lá sẽ tính sang tháng sau`
: `${d.t} · +${r.granted} Lá Bồ Đề` + (r.clipped ? ' (đã chạm trần tháng)' : ''));
},
joinProject(id) {
const p = STATE.projects.find(x => x.id === id);
if (!p || p.joined) return;
setState({ projects: STATE.projects.map(x => x.id === id ? Object.assign({}, x, { joined: true }) : x) });
const idx = Math.max(0, p.milestones.findIndex(m => !m.done));
actions.notify('connect', 'gold', 'Đã tham gia dự án', `${p.t} — mốc ${idx + 1} đã mở, hoàn thành để nhận +${p.milestones[idx].leaf} Lá.`);
},
leaveProject(id) {
const p = STATE.projects.find(x => x.id === id);
if (!p || !p.joined) return;
setState({ projects: STATE.projects.map(x => x.id === id ? Object.assign({}, x, { joined: false }) : x) });
actions.notify('connect', 'gold', 'Đã rời dự án', `${p.t} — tiến độ mốc được giữ lại, bạn có thể quay lại bất cứ lúc nào.`);
},
completeMilestone(pid) {
const p = STATE.projects.find(x => x.id === pid);
if (!p || !p.joined || p.completed) return;
const idx = p.milestones.findIndex(m => !m.done);
if (idx < 0) return;
const ms = p.milestones.map((m, i) => i === idx ? Object.assign({}, m, { done: true }) : m);
const completed = ms.every(m => m.done);
setState({ projects: STATE.projects.map(x => x.id === pid ? Object.assign({}, x, { milestones: ms, completed }) : x) });
const r = actions.addLeaves(p.milestones[idx].leaf, `Mốc “${p.milestones[idx].t}” — ${p.t}`, 'check');
if (completed) actions.notify('sparkle', 'ok', 'Hoàn thành dự án', `${p.t} — bạn đã đi trọn ${ms.length} mốc. Lành thay!`);
else actions.notify('connect', 'ok', `Hoàn thành mốc ${idx + 1}`, `${p.t} · +${r.granted} Lá — mốc ${idx + 2} đã mở.`);
},
vote(i) {
if (STATE.voted === i) return;
const prev = STATE.voted;
setState({ votes: STATE.votes.map((n, j) => j === i ? n + 1 : j === prev ? n - 1 : n), voted: i });
actions.notify('award', 'gold', 'Đã ghi nhận phiếu bình chọn', `Bạn bình chọn cho ${GALA_TOP[i][0]} — mỗi người 1 phiếu (demo).`);
},
askChat(q) {
const t = (q || '').trim();
if (!t) return;
setState({ chatLog: [...STATE.chatLog, { id: uid(), q: t, flow: matchChatFlow(t) }] });
},
setProfile(name, dharma) {
setState({ name: (name || '').trim() || STATE.name, dharma: (dharma || '').trim() || STATE.dharma });
},
resetDemo() {
Object.keys(DEED_TIMERS).forEach(k => { clearTimeout(DEED_TIMERS[k]); delete DEED_TIMERS[k]; });
try { localStorage.removeItem('sdtd_cover'); localStorage.removeItem('sdtd_avatar'); } catch (e) {}
STATE = seedState();
saveState();
LISTENERS.forEach(fn => { try { fn(STATE); } catch (e) {} });
actions.notify('sparkle', 'gold', 'Chào mừng đến với demo Cây Bồ Đề', 'Dữ liệu demo đã được đặt lại về trạng thái ban đầu.');
},
};
// reload trang khi còn deed chờ duyệt (persist) → lên lịch duyệt lại
try { STATE.deeds.filter(d => d.status === 'pending').forEach(d => actions.scheduleApproval(d.id)); } catch (e) {}
// ════════ HOME ════════
function HomeScreen({ nav }) {
const s = useStore();
const unread = unreadCount(s);
const monthPct = Math.min(1, s.monthLeaves / MONTH_CAP);
const capped = s.monthLeaves >= MONTH_CAP;
const si = treeStage(s.leaves);
const st = TREE_STAGES[si];
const next = TREE_STAGES[si + 1];
const treePct = next ? Math.min(1, (s.leaves - st.min) / (next.min - st.min)) : 1;
const latestApproved = s.deeds.find(d => d.status === 'approved');
const openProjects = s.projects.filter(p => !p.completed).slice(0, 2);
// đếm ngược Giai đoạn Khởi Động — tính live, không hardcode số ngày/% (số này KHÔNG thuộc bộ số GHIM)
const phaseLeft = Math.max(0, Math.ceil((PHASE_END - Date.now()) / 86400000));
const phasePct = Math.max(0, Math.min(100, Math.round((Date.now() - PHASE_START) / (PHASE_END - PHASE_START) * 100)));
return (
Nam mô A Di Đà Phật
{s.dharma}
nav && nav('notifications')} style={{ position: 'relative', width: 40, height: 40, borderRadius: 100, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
{unread > 0 && {unread > 9 ? '9+' : unread} }
{/* balance — đọc store, cập nhật live */}
Tổng Lá Bồ Đề
{fmt(s.leaves)}
{/* pills overlapping — #214 GHIM (demo), 2 pill kia đọc store */}
nav && nav('gala')} style={{ flex: 1, textAlign: 'center', padding: '12px 0' }}>Xếp hạng
#{s.rank}
nav && nav('record')} style={{ flex: 1, textAlign: 'center', padding: '12px 0' }}>Trần còn lại
{Math.max(0, MONTH_CAP - s.monthLeaves)}
nav && nav('record')} style={{ flex: 1, textAlign: 'center', padding: '12px 0' }}>Tháng này
+{s.monthLeaves}
{/* trần Lá/tháng — cơ chế Phòng Ác (live từ store; nổi bật khi chạm trần) */}
{s.monthLeaves} / {MONTH_CAP} Lá tháng này
{Math.round(monthPct * 100)}%
{capped ? 'Đã chạm trần tháng — cơ chế Phòng Ác đang giữ thiện hành thuần vì tâm' : `Trần ${MONTH_CAP} Lá/tháng — cơ chế Phòng Ác`}
{/* Thiện Tri Thức — hỏi chuyện đời, nhận lời kinh (mở ChatScreen — xem ghi chú điều hướng ở TabBar) */}
nav && nav('chat')} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 15, background: 'linear-gradient(135deg,#fff6e3,#ffe9bf)', borderColor: 'rgba(245,144,0,0.3)' }}>
🪷
Thiện Tri Thức
Hỏi chuyện đời, nhận lời kinh — bạn tự đọc, tự chiêm nghiệm
{/* cây bồ đề — đổi hình theo cấp (6 mức), nhãn cấp + tiến độ lên cấp kế */}
Cây Bồ Đề của bạn
Cấp {si + 1}/6 · {st.key}
{st.desc}
{next ? Còn {fmt(next.min - s.leaves)} Lá để lên cấp {si + 2} · {next.key} : Cây đã viên mãn — lành thay! }
{/* campaign phase */}
Giai đoạn: Khởi Động
T3 – T9 / 2026
Đang nhận hồ sơ & ghi nhận tiến độ dự án. Điểm tiếp tục tích lũy.
Đóng sổ 30/9/2026 · {phaseLeft > 0 ? còn {phaseLeft} ngày : đã đóng sổ }
{/* quick actions */}
nav && nav('checkin')} style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 11, padding: 14 }}>
nav && nav('connect')} style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 11, padding: 14 }}>
{/* đang cần hỗ trợ — đọc projects từ store */}
Đang cần hỗ trợ
nav && nav('connect')} style={{ color: C.saffronD, fontSize: 13, fontWeight: 700, cursor: 'pointer' }}>Xem tất cả ›
{openProjects.map(p => (
nav && nav('connect')} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 14 }}>
{p.t}
{p.joined ? `Đang tham gia · ${p.milestones.filter(m => m.done).length}/${p.milestones.length} mốc` : p.org}
~{p.milestones.filter(m => !m.done).reduce((a, m) => a + m.leaf, 0)}
))}
{/* verify chip — hiện xác thực mới nhất từ store (fallback seed) */}
nav && nav('record')} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 14, borderColor: 'rgba(245,144,0,0.3)' }}>
Đã được chùa xác thực
{latestApproved ? `${latestApproved.t} · hôm nay` : 'Chùa Hoằng Pháp · 09:41 hôm nay'}
{/* recent — lịch sử Lá từ store */}
Thiện hành gần đây
{s.history.slice(0, 4).map(a => (
+{a.leaf}
))}
);
}
// ════════ CHECK-IN ════════
const NEARBY_TEMPLES = [['Chùa Quán Sứ', '0.4 km', 'Hà Nội'], ['Chùa Trấn Quốc', '1.2 km', 'Tây Hồ'], ['Chùa Hương Sắc', '3.8 km', 'Hà Đông']];
function CheckInScreen({ nav }) {
const s = useStore();
const [active, setActive] = useState(null); // temple name → view chi tiết
const [phase, setPhase] = useState('idle'); // idle | qr | gps
const [gpsDone, setGpsDone] = useState(false);
const [success, setSuccess] = useState(null); // { name, granted }
const [toast, setToast] = useState(null);
const timers = useRef([]);
useEffect(() => () => { timers.current.forEach(clearTimeout); }, []); // clear mọi timer khi unmount
const later = (fn, ms) => { timers.current.push(setTimeout(fn, ms)); };
const showToast = msg => { setToast(msg); later(() => setToast(null), 2800); };
function tryCheckIn(name) {
const r = actions.checkIn(name);
if (!r.ok) { showToast('Hôm nay bạn đã check-in chùa này — hẹn ngày mai nhé.'); return false; }
setSuccess({ name, granted: r.granted });
return true;
}
function startQR() {
if (phase !== 'idle') return;
setSuccess(null);
setPhase('qr');
later(() => {
setPhase('idle');
const day = todayKey();
const pick = NEARBY_TEMPLES.find(t => !STATE.checkins.some(c => c.name === t[0] && c.day === day));
if (!pick) { showToast('Hôm nay bạn đã check-in cả 3 chùa lân cận.'); return; }
tryCheckIn(pick[0]);
}, 1500); // demo quét 1.5s
}
function startGPS() {
if (phase !== 'idle') return;
setSuccess(null);
setPhase('gps');
later(() => { setPhase('idle'); setGpsDone(true); }, 1300); // demo định vị 1.3s
}
// ── temple detail (sau check-in / từ lịch sử) ──
if (active) {
const info = TEMPLE_INFO[active];
return (
setActive(null)} style={{ background: 'rgba(255,255,255,0.2)', border: 0, color: '#fff', borderRadius: 100, padding: '6px 14px', fontSize: 12.5, fontWeight: 700, cursor: 'pointer', fontFamily: F, display: 'inline-flex', alignItems: 'center', gap: 5 }}>‹ Chùa lân cận
Đã check-in
{active}
{info.address} · {info.place}
Check-in để tìm hiểu chùa và nhận việc Cầu Nối
{/* temple info */}
{info.desc}
{info.acts.length}
Dự án / sự kiện
{/* activities — "Thêm Cầu Nối" ghi vào store.connectTasks */}
Dự án & sự kiện đang & sắp mở
Thêm vào danh sách Cầu Nối để nhận việc và đóng góp
{info.acts.map(a => {
const isJoined = s.connectTasks.some(x => x.t === a.t);
return (
{a.t}
{a.type}
{a.status === 'open' ? '● Đang mở' : '○ Sắp mở'}
Yêu cầu: {a.req}
Hạn: {a.due}
+{a.leaf}
{ isJoined ? actions.removeConnectTask(a.t) : actions.addConnectTask(active, a); }}
style={{ border: isJoined ? `1px solid ${C.saffron}` : 0, background: isJoined ? '#fff6e6' : 'linear-gradient(135deg,#fbc117,#f59000)', color: isJoined ? C.saffronD : '#5a2a08', fontWeight: 800, fontSize: 12.5, padding: '9px 16px', borderRadius: 100, cursor: 'pointer', fontFamily: F }}>
{isJoined ? '✓ Đã thêm Cầu Nối' : '+ Thêm Cầu Nối'}
);
})}
);
}
// ── default: scanner + GPS + nearby + lịch sử ──
return (
);
}
// ════════ RECORD ════════
// 11 loại thiện hành — gửi → "Đang chờ xác thực" → auto-demo 4s sau
// Trụ trì xác thực ✓ + cộng Lá (qua trần tháng nếu chạm Phòng Ác).
function RecordScreen() {
const s = useStore();
const [sel, setSel] = useState(0);
const [approver, setApprover] = useState('temple');
const [evidence, setEvidence] = useState(false); // demo: đính kèm minh chứng
const [sentId, setSentId] = useState(null); // id deed vừa gửi → view xác nhận live
const remaining = Math.max(0, MONTH_CAP - s.monthLeaves);
const approvers = [
{ key: 'temple', ic: 'temple', t: 'Trụ trì chùa xác nhận', d: 'Phù hợp hoạt động tại chùa bạn sinh hoạt' },
{ key: 'council', ic: 'shield', t: 'Hội đồng Ban Văn hóa TƯ xét duyệt', d: 'Cho tác phẩm, dự án quy mô lớn' },
];
const deedList = s.deeds.length > 0 && (
Ghi nhận của bạn
Demo: chùa xác thực tự động sau vài giây
{s.deeds.map(d => (
{d.t}
{d.time} hôm nay{d.evidence ? ' · có minh chứng' : ''}
{d.status === 'pending'
? Chờ xác thực
: {d.granted > 0 ? `Đã duyệt +${d.granted}` : 'Đã duyệt · trần tháng'} }
))}
);
if (sentId) {
const deed = s.deeds.find(d => d.id === sentId);
const approved = deed && deed.status === 'approved';
const ap = approvers.find(a => a.key === approver);
return (
{approved ? 'Trụ trì đã xác thực ✓' : 'Đã gửi ghi nhận'}
{approved
? (deed.granted > 0
? Thiện hành {deed.t} đã được duyệt — bạn nhận +{deed.granted} Lá Bồ Đề .
: Thiện hành {deed.t} đã được duyệt — đã chạm trần tháng, Lá sẽ tính sang tháng sau . )
: Thiện hành {deed ? deed.t : ''} của bạn đang chờ {ap.t.replace(' xác nhận', '').replace(' xét duyệt', '')} {approver === 'temple' ? 'xác nhận' : 'xét duyệt'}. }
Trạng thái: {approved ? (deed.granted > 0 ? 'Đã xác thực — Lá đã cộng' : 'Đã xác thực — đã chạm trần tháng') : 'Chờ chùa xác thực minh chứng'}
{deed && deed.evidence ? 'Đã đính kèm demo_minhchung.jpg · ' : ''}{approved ? (deed.granted > 0 ? 'Xem Trang chủ & Hồ sơ để thấy Lá cập nhật' : 'Trần 500 Lá/tháng (Phòng Ác) — Lá sẽ tính sang tháng sau') : 'Lá Bồ Đề được cộng sau khi được duyệt (demo: ~4 giây)'}
{ setSentId(null); setEvidence(false); }} style={{ marginTop: 18, border: `1px solid ${C.saffron}`, background: '#fff6e6', color: C.saffronD, fontWeight: 800, fontSize: 14, padding: '12px 24px', borderRadius: 100, cursor: 'pointer', fontFamily: F }}>Ghi nhận hoạt động khác
{deedList}
);
}
return (
{/* trần tháng còn lại — Phòng Ác */}
{remaining === 0 ? 'Đã chạm trần 500 Lá/tháng — Lá mới không cộng thêm (Phòng Ác)' : Còn {remaining} Lá trong trần {MONTH_CAP} Lá/tháng (Phòng Ác) }
{/* 11 loại thiện hành (khớp 11 lĩnh vực hệ thống) */}
{DEED_TYPES.map((t, i) => (
setSel(i)} style={{
display: 'flex', alignItems: 'center', gap: 13, padding: 15,
borderColor: sel === i ? C.saffron : C.line,
background: sel === i ? '#fff6e6' : C.card,
}}>
+{t[3]}
{sel===i && }
))}
{/* đơn vị xác nhận */}
Đơn vị xác nhận
Mọi thiện hành cần được xác nhận trước khi cộng Lá Bồ Đề
{approvers.map(a => (
setApprover(a.key)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 14, cursor: 'pointer',
borderColor: approver === a.key ? C.saffron : C.line, background: approver === a.key ? '#fff6e6' : C.card }}>
{approver===a.key && }
))}
{/* minh chứng — demo (Webapp có trạng thái "Chờ minh chứng") */}
Đính kèm minh chứng
Ảnh/video minh chứng giúp chùa xác thực nhanh hơn
setEvidence(e => !e)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 14, cursor: 'pointer',
borderColor: evidence ? C.saffron : C.line, background: evidence ? '#fff6e6' : C.card }}>
{evidence ? 'Đã đính kèm minh chứng' : 'Chụp ảnh / chọn từ thư viện'}
{evidence ? 'Chạm để gỡ tệp đính kèm' : 'Ảnh hoặc video về thiện hành của bạn'}
{evidence
? demo_minhchung.jpg ✓
: }
{ const id = actions.submitDeed({ typeIdx: sel, approver, evidence }); setSentId(id); }} style={{ width: '100%', border: 'none', background: 'linear-gradient(135deg,#fbc117,#f59000)', color: '#5a2a08', fontWeight: 800, fontSize: 16, padding: '15px 0', borderRadius: 100, fontFamily: F, cursor: 'pointer', boxShadow: '0 10px 30px rgba(245,144,0,0.34)' }}>
Gửi để xác nhận · +{DEED_TYPES[sel][3]} Lá
{deedList}
);
}
// ════════ GALA ════════
function GalaScreen({ nav }) {
// Top-3 chuẩn (đồng bộ Portal & Webapp): Minh Tuệ #1 · Diệu Tâm #2 · Nguyên Hạnh #3
// Bình chọn nối vào store — phiếu persist; 1 phiếu/người, đổi ứng viên = chuyển phiếu.
const s = useStore();
const medalColor = ['#f5b800', '#bcae97', '#d08a3e'];
const voted = s.voted, votes = s.votes;
return (
);
}
// ════════ PROFILE ════════
// Huy hiệu ĐỘNG — unlock theo điều kiện đọc từ store; khóa = mờ + điều kiện.
const BADGE_DEFS = [
{ ic: 'temple', t: 'Bước chân đầu', req: 'Check-in 1 chùa', cond: s => s.checkins.length >= 1 },
{ ic: 'check', t: 'Tam thiện hạnh', req: '3 thiện hành được duyệt', cond: s => approvedDeeds(s) >= 3 },
{ ic: 'connect', t: 'Người gieo duyên',req: 'Hoàn thành 1 mốc dự án', cond: s => anyMilestoneDone(s) },
{ ic: 'award', t: 'Một phiếu tín tâm',req: 'Bình chọn tại Gala', cond: s => s.voted != null },
{ ic: 'leaf', t: 'Cây tỏa bóng', req: 'Chạm cấp cây 5 (2.000 Lá)', cond: s => treeStage(s.leaves) >= 4 },
{ ic: 'star', t: 'Top 8%', req: '', cond: () => true },
];
function ProfileScreen({ nav }) {
const s = useStore();
const [cover, setCover] = useState(() => { try { return localStorage.getItem('sdtd_cover') || ''; } catch(e){ return ''; } });
const [avatar, setAvatar] = useState(() => { try { return localStorage.getItem('sdtd_avatar') || ''; } catch(e){ return ''; } });
const [editing, setEditing] = useState(false);
const [nm, setNm] = useState('');
const [dh, setDh] = useState('');
const [confirmReset, setConfirmReset] = useState(false);
const timers = useRef([]);
const mounted = useRef(true);
useEffect(() => () => { mounted.current = false; timers.current.forEach(clearTimeout); }, []);
// đồng bộ ảnh bìa/avatar với localStorage mỗi khi store đổi — để resetDemo()
// (đã removeItem 'sdtd_cover'/'sdtd_avatar') xóa được ảnh đang hiển thị.
useEffect(() => {
try {
setCover(localStorage.getItem('sdtd_cover') || '');
setAvatar(localStorage.getItem('sdtd_avatar') || '');
} catch (e) {}
}, [s]);
const later = (fn, ms) => { timers.current.push(setTimeout(fn, ms)); };
function pick(setter, key) {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/*';
inp.onchange = () => {
const f = inp.files[0]; if (!f) return;
const r = new FileReader();
// lưu localStorage vô điều kiện; setState chỉ khi còn mounted (đổi tab giữa chừng)
r.onload = () => { try { localStorage.setItem(key, r.result); } catch(e){} if (mounted.current) setter(r.result); };
r.readAsDataURL(f);
};
inp.click();
}
function startEdit() { setNm(s.name); setDh(s.dharma); setEditing(true); }
function askReset() {
if (confirmReset) { setConfirmReset(false); setEditing(false); actions.resetDemo(); return; }
setConfirmReset(true);
later(() => setConfirmReset(false), 3000);
}
const inpStyle = { width: '100%', boxSizing: 'border-box', border: `1px solid ${C.line}`, borderRadius: 12, padding: '10px 13px', fontFamily: F, fontSize: 13.5, color: C.ink, background: C.card, outline: 'none' };
const monthPct = Math.min(1, s.monthLeaves / MONTH_CAP);
return (
{/* cover */}
pick(setCover, 'sdtd_cover')} style={{ height: 150, paddingTop: 0, cursor: 'pointer',
background: cover ? `center/cover no-repeat url(${cover})` : 'linear-gradient(165deg,#ffd35e 0%, #f59000 60%, #e07700 100%)',
borderRadius: '0 0 24px 24px', position: 'relative' }}>
Ảnh bìa
{/* avatar + name (sửa được — lưu store) */}
pick(setAvatar, 'sdtd_avatar')} style={{ width: 92, height: 92, borderRadius: 100, margin: '0 auto', border: '4px solid #fff', position: 'relative', cursor: 'pointer', overflow: 'hidden',
background: avatar ? `center/cover no-repeat url(${avatar})` : 'linear-gradient(150deg,#ffd35e,#f59000)', boxShadow: '0 6px 18px rgba(245,144,0,0.25)' }}>
{!avatar &&
}
{editing ? (
setDh(e.target.value)} placeholder="Pháp danh" style={inpStyle}/>
setNm(e.target.value)} placeholder="Họ tên" style={inpStyle}/>
{ actions.setProfile(nm, dh); setEditing(false); }} style={{ border: 'none', background: 'linear-gradient(135deg,#fbc117,#f59000)', color: '#5a2a08', fontWeight: 800, fontSize: 12.5, padding: '9px 20px', borderRadius: 100, cursor: 'pointer', fontFamily: F }}>Lưu
setEditing(false)} style={{ border: `1px solid ${C.line}`, background: C.card, color: C.soft, fontWeight: 700, fontSize: 12.5, padding: '9px 16px', borderRadius: 100, cursor: 'pointer', fontFamily: F }}>Hủy
) : (
{s.dharma}
Sửa
{s.name} · Phật tử · Tham gia từ 05/2026
)}
{[[fmt(s.leaves), 'Lá Bồ Đề'], [String(18 + approvedDeeds(s)), 'Thiện hành'], ['#' + s.rank, 'Xếp hạng']].map(st2 => (
{st2[0]}
{st2[1]}
))}
{/* trần Lá/tháng — cơ chế Phòng Ác (live từ store, khớp Trang chủ) */}
{s.monthLeaves} / {MONTH_CAP} Lá tháng này
{Math.round(monthPct * 100)}%
Trần {MONTH_CAP} Lá/tháng — cơ chế Phòng Ác
Chùa đã check-in
nav && nav('checkin')} style={{ color: C.saffronD, fontSize: 13, fontWeight: 700, cursor: 'pointer' }}>+ Check-in ›
{s.checkins.map(c => (
nav && nav('checkin')} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 14, cursor: 'pointer' }}>
{c.name}
{c.place} · {c.date}
Đã đến
))}
{/* thành tựu — huy hiệu động, khóa hiện mờ + điều kiện */}
Thành tựu tu tập
{BADGE_DEFS.map(b => {
const on = b.cond(s);
return (
{b.t}
{!on && {b.req}
}
);
})}
{/* lịch sử Lá Bồ Đề — từ store */}
Lịch sử Lá Bồ Đề
{s.history.slice(0, 6).map(a => (
+{a.leaf}
))}
Hồ sơ công khai
sangdaotrongdoi.net/u/minhan
{/* đặt lại dữ liệu demo — confirm 2 chạm */}
{confirmReset ? 'Chạm lần nữa để xác nhận đặt lại' : 'Đặt lại dữ liệu demo'}
);
}
// ════════ CẦU NỐI (Connect) ════════
// 3 dự án từ store với mốc tuần tự: Tham gia → mốc 1 mở → hoàn thành mốc
// (+15–50 Lá theo từng mốc, đồng bộ Webapp; confirm 2 chạm) → mở mốc kế → đủ mốc = hoàn thành.
function ConnectScreen() {
const s = useStore();
const [cat, setCat] = useState('Tất cả');
const [confirmId, setConfirmId] = useState(null); // confirm nhẹ cho "Hoàn thành mốc"
const timers = useRef([]);
useEffect(() => () => { timers.current.forEach(clearTimeout); }, []);
const later = (fn, ms) => { timers.current.push(setTimeout(fn, ms)); };
function askComplete(pid) {
if (confirmId === pid) { setConfirmId(null); actions.completeMilestone(pid); return; }
setConfirmId(pid);
later(() => setConfirmId(c => c === pid ? null : c), 2600);
}
// chip lọc suy ra từ thực tế dữ liệu — phủ cả cat của việc nhận từ check-in chùa
// ('Tu học', 'Thiện nguyện', 'Di sản', 'Truyền thông', 'Cộng đồng'…), không hardcode 3 nhóm.
const cats = ['Tất cả', ...new Set([...s.connectTasks, ...s.projects].map(x => x.cat))];
const tasks = s.connectTasks.filter(p => cat === 'Tất cả' || p.cat === cat);
const projects = s.projects.filter(p => cat === 'Tất cả' || p.cat === cat);
const total = tasks.length + projects.length;
return (
Cầu Nối là gì?
Nơi chùa & đạo tràng đăng dự án thiện nguyện đang cần người chung tay. Bạn “gieo duyên” tham gia, hoàn thành từng mốc và được ghi nhận +15–50 Lá Bồ Đề mỗi mốc .
{cats.map(c => (
setCat(c)} style={{ flexShrink: 0, border: 0, cursor: 'pointer', fontFamily: F, fontSize: 13, fontWeight: 700,
padding: '8px 15px', borderRadius: 100,
background: cat === c ? 'linear-gradient(135deg,#fbc117,#f59000)' : '#fff',
color: cat === c ? '#5a2a08' : C.soft, boxShadow: cat === c ? '0 6px 16px rgba(245,144,0,0.3)' : `inset 0 0 0 1px ${C.line}` }}>{c}
))}
{total} dự án cần hỗ trợ
{/* việc nhận từ check-in chùa */}
{tasks.map(p => (
{p.t}
{p.org}
Đã nhận từ check-in
{p.need}{p.due ? ` · hạn ${p.due}` : ''}
~{p.leaf}
actions.removeConnectTask(p.t)} style={{ border: `1px solid ${C.line}`, background: C.card, color: C.soft, fontWeight: 700, fontSize: 11.5, padding: '7px 13px', borderRadius: 100, cursor: 'pointer', fontFamily: F }}>Bỏ nhận
))}
{/* dự án Cầu Nối — vòng đời đầy đủ */}
{projects.map(p => {
const doneCount = p.milestones.filter(m => m.done).length;
const openIdx = p.milestones.findIndex(m => !m.done);
return (
{p.t}
{p.org}
{p.cat}
{p.completed && Dự án hoàn thành }
{p.joined && !p.completed && ● Đang tham gia · {doneCount}/{p.milestones.length} mốc }
{!p.joined && !p.completed && (
{p.need}
~{p.milestones.reduce((a, m) => a + m.leaf, 0)}
actions.joinProject(p.id)} style={{ border: 'none', background: 'linear-gradient(135deg,#fbc117,#f59000)', color: '#5a2a08', fontWeight: 800, fontSize: 12.5, padding: '8px 16px', borderRadius: 100, cursor: 'pointer', fontFamily: F, boxShadow: '0 6px 16px rgba(245,144,0,0.3)' }}>Gieo duyên
)}
{/* panel mốc — mốc 1 mở, còn lại khóa tuần tự */}
{(p.joined || p.completed) && (
{p.milestones.map((m, i) => {
const stt = m.done ? 'done' : (p.joined && i === openIdx ? 'open' : 'locked');
return (
{stt === 'done' ? : stt === 'locked' ? : i + 1}
{m.t}
{stt === 'open' && (
askComplete(p.id)} style={{ flexShrink: 0, border: confirmId === p.id ? `1px solid ${C.saffron}` : 'none',
background: confirmId === p.id ? '#fff6e6' : 'linear-gradient(135deg,#fbc117,#f59000)',
color: confirmId === p.id ? C.saffronD : '#5a2a08', fontWeight: 800, fontSize: 11, padding: '7px 12px', borderRadius: 100, cursor: 'pointer', fontFamily: F }}>
{confirmId === p.id ? 'Chạm để xác nhận ✓' : `Hoàn thành mốc +${m.leaf}`}
)}
{stt === 'done' && +{m.leaf} }
);
})}
{p.joined && !p.completed && (
actions.leaveProject(p.id)} style={{ alignSelf: 'flex-end', color: C.faint, fontSize: 11.5, fontWeight: 700, cursor: 'pointer', textDecoration: 'underline' }}>Rời dự án
)}
{p.completed && (
Bạn đã đi trọn {p.milestones.length} mốc của dự án này. Lành thay!
)}
)}
);
})}
);
}
// ════════ THÔNG BÁO (Notifications) ════════
// Render từ store (mới nhất trên đầu) — tap để đánh dấu đã đọc, nút "Đọc tất cả".
function NotificationScreen() {
const s = useStore();
const unread = unreadCount(s);
const groups = [
{ day: 'Hôm nay', items: s.notifications.filter(n => n.day === 'Hôm nay') },
{ day: 'Trước đó', items: s.notifications.filter(n => n.day !== 'Hôm nay') },
].filter(g => g.items.length > 0);
const toneColor = { ok: '#3f8a3f', gold: C.saffronD };
const toneBg = { ok: 'rgba(63,138,63,0.12)', gold: 'rgba(245,144,0,0.12)' };
return (
{unread > 0 ? `${unread} thông báo chưa đọc` : 'Bạn đã đọc tất cả'}
actions.markAllRead()} disabled={unread === 0}
style={{ border: `1px solid ${unread > 0 ? C.saffron : C.line}`, background: unread > 0 ? '#fff6e6' : C.card, color: unread > 0 ? C.saffronD : C.faint, fontWeight: 800, fontSize: 11.5, padding: '7px 14px', borderRadius: 100, cursor: unread > 0 ? 'pointer' : 'default', fontFamily: F }}>
Đọc tất cả
{groups.map(g => (
{g.day}
{g.items.map(n => (
n.unread && actions.markRead(n.id)} style={{ display: 'flex', gap: 12, padding: 14, borderColor: n.unread ? 'rgba(245,144,0,0.3)' : C.line, background: n.unread ? '#fffaf0' : C.card, cursor: n.unread ? 'pointer' : 'default' }}>
{n.time}
{n.unread && }
))}
))}
);
}
// ════════ THIỆN TRI THỨC (Chat) ════════
// Demo không backend. Input TỰ DO → keyword matching (mượn matchFlow của
// thien-tri-thuc.html). Trích dẫn kinh văn dùng PLACEHOLDER (chờ Ban Văn hóa
// kiểm định bản dịch) — tuyệt đối không chèn bản dịch kinh thật vào demo.
function CiteCard({ src, tags }) {
return (
[Trích dẫn kinh văn — chờ Ban Văn hóa kiểm định]
{src}
{tags.map(t => {t} )}
);
}
function ChatScreen({ nav }) {
const s = useStore();
const [text, setText] = useState('');
const askedQs = s.chatLog.map(e => e.q);
const remaining = CHAT_FLOWS.map(f => f.q).filter(q => !askedQs.includes(q));
function send() {
const t = text.trim();
if (!t) return;
actions.askChat(t);
setText('');
}
return (
{/* header */}
nav && nav('home')} style={{ background: 'rgba(255,255,255,0.2)', border: 0, color: '#fff', borderRadius: 100, padding: '6px 14px', fontSize: 12.5, fontWeight: 700, cursor: 'pointer', fontFamily: F, display: 'inline-flex', alignItems: 'center', gap: 5 }}>‹ Trang chủ
🪷
Thiện Tri Thức
Không trả lời thay — chỉ gửi trích dẫn nguyên văn kèm nguồn
{/* messages — log persist trong store */}
{s.chatLog.map(entry => {
const flow = entry.flow >= 0 ? CHAT_FLOWS[entry.flow] : null;
const cites = flow ? flow.cites : CHAT_FALLBACK.cites;
const ctx = flow ? flow.ctx : CHAT_FALLBACK.ctx;
return (
{/* user bubble */}
{entry.q}
{/* bot bubble */}
Bạn đang chạm tới chủ đề {ctx} . Trong kho kinh sách đã kiểm định có {cites.length} trích dẫn liên quan đến điều bạn chia sẻ:
{cites.map(c =>
)}
Mời bạn tự đọc và chiêm nghiệm
{remaining.length > 0 &&
actions.askChat(remaining[0])} style={{ flexShrink: 0, border: `1px solid ${C.saffron}`, background: '#fff6e6', color: C.saffronD, fontWeight: 800, fontSize: 11.5, padding: '7px 13px', borderRadius: 100, cursor: 'pointer', fontFamily: F }}>Hỏi sâu thêm }
);
})}
{/* suggestion chips + input bar (gõ tự do → keyword matching) */}
{remaining.length > 0 && (
{remaining.map(q => (
actions.askChat(q)} style={{ flexShrink: 0, border: `1px solid ${C.line}`, background: C.card, color: C.soft, fontWeight: 700, fontSize: 12, padding: '8px 14px', borderRadius: 100, cursor: 'pointer', fontFamily: F }}>{q}
))}
)}
setText(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') send(); }}
placeholder="Chia sẻ điều bạn đang trăn trở…"
style={{ flex: 1, minWidth: 0, background: C.card, border: `1px solid ${C.line}`, borderRadius: 100, padding: '12px 17px', color: C.ink, fontSize: 13, fontFamily: F, outline: 'none' }}/>
);
}
// ════════ TAB BAR ════════
// LƯU Ý ĐIỀU HƯỚNG (đặc tả Đợt 2 — mục 5.4): KHÔNG thêm tab thứ 6 cho Thiện Tri Thức.
// TabBar giữ nguyên 5 vị trí (4 tab + nút Ghi nhận ở giữa); ChatScreen chỉ được mở
// từ card "🪷 Thiện Tri Thức" trên Trang chủ (HomeScreen).
// Badge thông báo: subscribe store — hiện SỐ chưa đọc, ẩn khi 0.
function TabBar({ tab, setTab }) {
const s = useStore();
const unread = unreadCount(s);
const tabs = [['home','Trang chủ','home'],['connect','Cầu Nối','connect'],['record','',''],['notifications','Thông báo','bell'],['profile','Hồ sơ','person']];
return (
{tabs.map((t) => {
if (t[0] === 'record') return (
setTab('record')} style={{ background: 'none', border: 'none', cursor: 'pointer', transform: 'translateY(-8px)' }}>
);
const active = tab === t[0];
return (
setTab(t[0])} style={{ position: 'relative', background: 'none', border: 'none', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, color: active ? C.saffron : '#b49a6f' }}>
{t[0] === 'notifications' && unread > 0 && {unread > 9 ? '9+' : unread} }
{t[1]}
);
})}
);
}
Object.assign(window, { HomeScreen, CheckInScreen, RecordScreen, GalaScreen, ProfileScreen, ConnectScreen, NotificationScreen, ChatScreen, TabBar, Icon });