diff --git a/plugin/index.test.ts b/plugin/index.test.ts index 365bae27..26157714 100644 --- a/plugin/index.test.ts +++ b/plugin/index.test.ts @@ -525,6 +525,7 @@ describe("POST /loglife/register handler", () => { let mockWriteFileSync: ReturnType; let mockUtimesSync: ReturnType; let usersJsonContent: string; + let openclawJsonContent: string; beforeEach(async () => { vi.resetModules(); @@ -533,12 +534,23 @@ describe("POST /loglife/register handler", () => { users: [], defaults: { dmScope: "main" }, }); + openclawJsonContent = JSON.stringify({ + agents: { list: [{ id: "main", name: "Main Agent" }] }, + bindings: [], + channels: { whatsapp: { groupPolicy: "allowlist" } }, + session: {}, + }); mockWriteFileSync = vi.fn(); mockUtimesSync = vi.fn(); vi.doMock("node:fs", () => ({ - readFileSync: vi.fn().mockImplementation(() => usersJsonContent), + readFileSync: vi.fn().mockImplementation((path: string) => { + if (path.includes("users.json")) return usersJsonContent; + if (path.includes("peer-agent-assignments.json")) return "{}"; + if (path.includes("openclaw.json")) return openclawJsonContent; + return "# AGENTS.md\n"; + }), writeFileSync: mockWriteFileSync, mkdirSync: vi.fn(), existsSync: vi.fn().mockReturnValue(true), @@ -624,10 +636,49 @@ describe("POST /loglife/register handler", () => { expect(body.userId).toBeDefined(); expect(body.linkCode).toMatch(/^LF-\d{4}$/); - // users.json, generated.json, openclaw.json, pending-links.json - expect(mockWriteFileSync).toHaveBeenCalledTimes(4); + // users.json, generated.json, openclaw.json, pending-links.json, + // plus peer-agent-assignments.json cleanup for stale sticky routes + expect(mockWriteFileSync).toHaveBeenCalledTimes(5); // utimesSync no longer used — openclaw.json is written directly expect(mockUtimesSync).not.toHaveBeenCalled(); + + const openclawWrite = mockWriteFileSync.mock.calls.find(([path]) => + String(path).includes("openclaw.json"), + ); + expect(openclawWrite).toBeDefined(); + const writtenOpenclaw = JSON.parse(String(openclawWrite![1])) as { + agents?: { list?: Array<{ id: string }> }; + }; + expect(writtenOpenclaw.agents?.list?.map((a) => a.id)).toEqual(["main", "alice"]); + }); + + it("bootstraps explicit main agent when openclaw.json has none", async () => { + openclawJsonContent = JSON.stringify({ + agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } }, + bindings: [], + channels: { whatsapp: { groupPolicy: "allowlist" } }, + session: {}, + }); + + const req = mockReq({ + method: "POST", + url: "/loglife/register", + headers: { authorization: `Bearer ${API_KEY}` }, + body: { phone: "+15551234567", name: "Alice" }, + }); + const res = mockRes(); + await registerHandler(req, res); + + expect(res._status).toBe(200); + const openclawWrite = mockWriteFileSync.mock.calls.find(([path]) => + String(path).includes("openclaw.json"), + ); + expect(openclawWrite).toBeDefined(); + const writtenOpenclaw = JSON.parse(String(openclawWrite![1])) as { + agents?: { list?: Array<{ id: string; name?: string }> }; + }; + expect(writtenOpenclaw.agents?.list?.map((a) => a.id)).toEqual(["main", "alice"]); + expect(writtenOpenclaw.agents?.list?.[0]?.name).toBe("Main"); }); it("uses name to derive user ID", async () => { @@ -664,8 +715,8 @@ describe("POST /loglife/register handler", () => { expect(body.existing).toBe(true); expect(body.linkCode).toMatch(/^LF-\d{4}$/); - // Existing user still gets a fresh pending-link entry on disk - expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + // Existing user refreshes config, syncs workspace AGENTS, and writes pending-link entry. + expect(mockWriteFileSync).toHaveBeenCalledTimes(4); expect(mockWriteFileSync).toHaveBeenCalledWith( expect.stringContaining("pending-links.json"), expect.any(String), @@ -682,6 +733,7 @@ describe("POST /loglife/unregister handler", () => { let unregisterHandler: RouteHandler; let mockWriteFileSync: ReturnType; + let mockRmSync: ReturnType; let usersJsonContent: string; beforeEach(async () => { @@ -696,10 +748,17 @@ describe("POST /loglife/unregister handler", () => { }); mockWriteFileSync = vi.fn(); + mockRmSync = vi.fn(); vi.doMock("node:fs", () => ({ readFileSync: vi.fn().mockImplementation((path: string) => { if (path.includes("users.json")) return usersJsonContent; + if (path.includes("peer-agent-assignments.json")) { + return JSON.stringify({ + "whatsapp:default:dm:+15551234567": "alice", + "whatsapp:default:dm:+19999999999": "main", + }); + } return JSON.stringify({ agents: { defaults: { model: { primary: "x" } } }, channels: { whatsapp: { groupPolicy: "allowlist", debounceMs: 0, mediaMaxMb: 50 } }, @@ -707,6 +766,7 @@ describe("POST /loglife/unregister handler", () => { }); }), writeFileSync: mockWriteFileSync, + rmSync: mockRmSync, mkdirSync: vi.fn(), existsSync: vi.fn().mockReturnValue(true), })); @@ -774,6 +834,7 @@ describe("POST /loglife/unregister handler", () => { expect(res._status).toBe(200); expect(res.json()).toEqual({ removed: false, existing: false }); expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(mockRmSync).not.toHaveBeenCalled(); }); it("unregisters matching phone and rewrites config files", async () => { @@ -791,8 +852,16 @@ describe("POST /loglife/unregister handler", () => { expect(body.removed).toBe(true); expect(body.removedUserIds).toEqual(["alice"]); - // users.json, generated.json, openclaw.json - expect(mockWriteFileSync).toHaveBeenCalledTimes(3); + // users.json, generated.json, openclaw.json, peer-agent-assignments.json + expect(mockWriteFileSync).toHaveBeenCalledTimes(4); + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining("/agents/alice"), + { recursive: true, force: true }, + ); + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining("/workspace-alice"), + { recursive: true, force: true }, + ); }); it("supports all:true to clear all users", async () => { @@ -810,7 +879,23 @@ describe("POST /loglife/unregister handler", () => { expect(body.removedAll).toBe(true); expect(body.removed).toBe(true); expect(body.removedUserIds).toEqual(["alice", "bob"]); - expect(mockWriteFileSync).toHaveBeenCalledTimes(4); + expect(mockWriteFileSync).toHaveBeenCalledTimes(5); + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining("/agents/alice"), + { recursive: true, force: true }, + ); + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining("/workspace-alice"), + { recursive: true, force: true }, + ); + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining("/agents/bob"), + { recursive: true, force: true }, + ); + expect(mockRmSync).toHaveBeenCalledWith( + expect.stringContaining("/workspace-bob"), + { recursive: true, force: true }, + ); }); }); diff --git a/plugin/index.ts b/plugin/index.ts index 655ad42c..bdd4feb4 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; -import { readFile } from "node:fs/promises"; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; +import { readFileSync, writeFileSync, mkdirSync, existsSync, utimesSync, rmSync } from "node:fs"; import { join } from "node:path"; import { timingSafeEqual, randomInt, createHash } from "node:crypto"; import { URL } from "node:url"; @@ -29,6 +29,7 @@ const LINK_CODE_REGEX = /^LF-\d{4}$/; const LINK_WELCOME_TEXT = "Welcome to LogLife! Your WhatsApp is connected. Tip: Send a quick voice note about why you're trying LogLife to get started."; export const verificationCodes = new Map(); +export const telegramVerificationCodes = new Map(); type PendingLink = { code: string; @@ -95,6 +96,12 @@ type SendWhatsApp = ( options: { verbose: boolean }, ) => Promise<{ messageId: string; toJid: string }>; +type SendTelegram = ( + to: string, + body: string, + options?: { verbose: boolean }, +) => Promise; + async function sendWhatsAppMessage( sendFn: SendWhatsApp, to: string, @@ -109,6 +116,70 @@ async function sendWhatsAppMessage( } } +async function sendTelegramMessage( + sendFn: SendTelegram | undefined, + to: string, + message: string, +): Promise<{ ok: boolean; error?: string }> { + if (!sendFn) { + return { ok: false, error: "Telegram channel is not configured on OpenClaw" }; + } + + try { + await sendFn(to, message, { verbose: false }); + return { ok: true }; + } catch { + // Fallback for runtimes that do not accept options. + try { + await sendFn(to, message); + return { ok: true }; + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + return { ok: false, error: errMsg }; + } + } +} + +function normalizeTelegramPeer(raw: string): string { + const value = raw.trim().replace(/^telegram:/i, "").replace(/^@/, ""); + return value; +} + +function toTelegramIdentifier(raw: string): string { + return `telegram:${normalizeTelegramPeer(raw)}`; +} + +function extractTelegramChatId(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + const prefixed = trimmed.match(/^telegram:([A-Za-z0-9_+-]+)$/i); + if (prefixed) return prefixed[1]; + + const directKey = trimmed.match(/:direct:([A-Za-z0-9_+-]+)$/i); + if (directKey) return directKey[1]; + + if (/^-?\d+$/.test(trimmed)) return trimmed; + return null; +} + +function sessionLikelyTelegram(session: Record): boolean { + const delivery = session.deliveryContext as Record | undefined; + const origin = session.origin as Record | undefined; + const values = [ + session.lastChannel, + session.lastTo, + delivery?.channel, + delivery?.to, + origin?.provider, + origin?.surface, + origin?.from, + origin?.to, + ]; + return values.some((v) => typeof v === "string" && v.toLowerCase().includes("telegram")); +} + function loadUsersJson(usersJsonPath: string): UsersConfig { if (!existsSync(usersJsonPath)) { return { users: [], defaults: { dmScope: "main" } }; @@ -146,7 +217,15 @@ function applyGeneratedConfigToOpenclaw( if (channel) previousManagedChannels.add(channel); } - ocRaw.agents = { ...ocRaw.agents, list: generated.agents.list }; + const existingAgentList = Array.isArray(ocRaw.agents?.list) + ? (ocRaw.agents.list as Array>) + : []; + const preservedMainAgent = existingAgentList.find((agent) => agent?.id === "main"); + const ensuredMainAgent = preservedMainAgent ?? { id: "main", name: "Main" }; + const generatedAgentsWithoutMain = generated.agents.list.filter((agent) => agent.id !== "main"); + const mergedAgentList = [ensuredMainAgent, ...generatedAgentsWithoutMain]; + + ocRaw.agents = { ...ocRaw.agents, list: mergedAgentList }; ocRaw.bindings = generated.bindings; ocRaw.session = { ...ocRaw.session, ...generated.session }; @@ -213,6 +292,71 @@ function deriveUserId(phone: string, name: string | undefined, config: UsersConf return `user-${hash}`; } +const AUDIO_METADATA_RULE_BEGIN = ""; +const AUDIO_METADATA_RULE_END = ""; +const WORKSPACE_TEMPLATE_FILES = [ + "BOOTSTRAP.md", + "HEARTBEAT.md", + "IDENTITY.md", + "SOUL.md", + "TOOLS.md", + "USER.md", +] as const; +const AUDIO_METADATA_RULE_BLOCK = `${AUDIO_METADATA_RULE_BEGIN} +# Rule: Save inbound audio metadata + +When the agent receives any inbound audio/voice message, do the following automatically: + +1. Create the audio_metadata folder in the user workspace if not already exist +2. Save a JSON file named .json in that folder containing these fields only: + +{ + "source_path": "", + "transcription": "", + "duration_seconds": , + "format": "", + "size_bytes": , + "modified": "" +} +3. When the local media file is available, read the real duration from the audio metadata/container headers and store that value in duration_seconds (do not estimate from transcript length). +4. If the local media file is not available, still create the JSON with nulls for missing values and include any available message metadata. +5. After saving, don't let the user know where you're saving; just provide a normal reply back. + +Security note: treat this folder as private (may contain transcripts). + +(End rule) +${AUDIO_METADATA_RULE_END}`; + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function upsertAudioMetadataRuleInAgentsFile( + agentsFilePath: string, + fallbackAgentsFilePath?: string, +): void { + let existing = ""; + if (existsSync(agentsFilePath)) { + existing = readFileSync(agentsFilePath, "utf-8"); + } else if (fallbackAgentsFilePath && existsSync(fallbackAgentsFilePath)) { + // Preserve baseline instructions when per-user AGENTS.md is created for the first time. + existing = readFileSync(fallbackAgentsFilePath, "utf-8"); + } else { + existing = "# AGENTS.md\n"; + } + + const pattern = new RegExp( + `${escapeForRegex(AUDIO_METADATA_RULE_BEGIN)}[\\s\\S]*?${escapeForRegex(AUDIO_METADATA_RULE_END)}`, + "m", + ); + + const updated = pattern.test(existing) + ? existing.replace(pattern, AUDIO_METADATA_RULE_BLOCK) + : `${existing}${existing.endsWith("\n") ? "\n" : "\n\n"}${AUDIO_METADATA_RULE_BLOCK}\n`; + + if (updated !== existing || !existsSync(agentsFilePath)) writeFileSync(agentsFilePath, updated); +} + const plugin = { id: "loglife", name: "LogLife", @@ -244,8 +388,34 @@ const plugin = { const generatedJsonPath = join(multiUserDir, "generated.json"); const pendingLinksPath = join(multiUserDir, "pending-links.json"); const openclawJsonPath = join(stateDir, "openclaw.json"); + const peerAgentAssignmentsPath = join(stateDir, "peer-agent-assignments.json"); const sendWA = api.runtime.channel.whatsapp.sendMessageWhatsApp as SendWhatsApp; + const sendTG = ( + (api.runtime.channel as Record).telegram as + | { sendMessageTelegram?: SendTelegram } + | undefined + )?.sendMessageTelegram; + const ensureUserWorkspaceAudioMetadataRule = (userId: string) => { + const workspaceDir = join(stateDir, `workspace-${userId}`); + const agentsFilePath = join(workspaceDir, "AGENTS.md"); + const baseAgentsFilePath = join(stateDir, "workspace", "AGENTS.md"); + const baseWorkspaceDir = join(stateDir, "workspace"); + + try { + mkdirSync(workspaceDir, { recursive: true }); + for (const fileName of WORKSPACE_TEMPLATE_FILES) { + const sourcePath = join(baseWorkspaceDir, fileName); + const destinationPath = join(workspaceDir, fileName); + if (!existsSync(sourcePath) || existsSync(destinationPath)) continue; + writeFileSync(destinationPath, readFileSync(sourcePath, "utf-8")); + } + upsertAudioMetadataRuleInAgentsFile(agentsFilePath, baseAgentsFilePath); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.warn(`Failed to sync audio metadata AGENTS rule for "${userId}": ${errMsg}`); + } + }; const persistPendingLinks = () => { mkdirSync(multiUserDir, { recursive: true }); @@ -263,6 +433,84 @@ const plugin = { if (removed) persistPendingLinks(); }; + const clearPeerAssignmentsForPhone = (phone: string) => { + if (!existsSync(peerAgentAssignmentsPath)) return; + + const phoneNorm = phone.trim().toLowerCase(); + const phoneDigits = phoneNorm.replace(/[^0-9]/g, ""); + if (!phoneDigits) return; + + const shouldDropAssignment = (assignmentKey: string): boolean => { + const keyNorm = assignmentKey.trim().toLowerCase(); + if (!keyNorm.startsWith("whatsapp:") && !keyNorm.startsWith("signal:")) { + return false; + } + + // Match explicit +E164 keys and JID-style keys (digits-only fallback). + if (keyNorm.includes(phoneNorm)) return true; + const keyDigits = keyNorm.replace(/[^0-9]/g, ""); + return keyDigits.includes(phoneDigits); + }; + + try { + const raw = JSON.parse(readFileSync(peerAgentAssignmentsPath, "utf-8")) as Record; + const next: Record = {}; + let changed = false; + + for (const [assignmentKey, assignedAgentId] of Object.entries(raw)) { + if (typeof assignedAgentId !== "string") continue; + if (shouldDropAssignment(assignmentKey)) { + changed = true; + continue; + } + next[assignmentKey] = assignedAgentId; + } + + if (changed) { + writeFileSync(peerAgentAssignmentsPath, JSON.stringify(next, null, 2) + "\n"); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.warn(`Failed to clear peer-agent assignments for ${phone}: ${errMsg}`); + } + }; + + const cleanupUserRuntimeState = (userIds: string[]) => { + const targets = [...new Set(userIds.map((id) => id.trim()).filter(Boolean))]; + if (targets.length === 0) return; + + for (const userId of targets) { + // Never delete the primary agent by mistake. + if (userId === "main") continue; + rmSync(join(stateDir, "agents", userId), { recursive: true, force: true }); + rmSync(join(stateDir, `workspace-${userId}`), { recursive: true, force: true }); + } + + if (!existsSync(peerAgentAssignmentsPath)) return; + + try { + const raw = JSON.parse(readFileSync(peerAgentAssignmentsPath, "utf-8")) as Record; + const next: Record = {}; + let changed = false; + + for (const [assignmentKey, assignedAgentId] of Object.entries(raw)) { + if (typeof assignedAgentId !== "string") continue; + if (targets.includes(assignedAgentId)) { + changed = true; + continue; + } + next[assignmentKey] = assignedAgentId; + } + + if (changed) { + writeFileSync(peerAgentAssignmentsPath, JSON.stringify(next, null, 2) + "\n"); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.warn(`Failed to clean peer-agent assignments: ${errMsg}`); + } + }; + try { if (existsSync(pendingLinksPath)) { const raw = JSON.parse(readFileSync(pendingLinksPath, "utf-8")) as { pending?: PendingLink[] }; @@ -299,6 +547,7 @@ const plugin = { const generated = generateConfig(usersConfig); writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + cleanupUserRuntimeState(removed.map((u) => u.id)); return { removed: true, removedUserIds: removed.map((u) => u.id) }; }; @@ -409,6 +658,7 @@ const plugin = { api.registerHttpRoute({ path: "/loglife/sessions", + auth: "plugin", handler: async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== "GET") { jsonResponse(res, 405, { error: "Method not allowed" }); @@ -500,6 +750,7 @@ const plugin = { api.registerHttpRoute({ path: "/loglife/verify/send", + auth: "plugin", handler: async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== "POST") { jsonResponse(res, 405, { error: "Method not allowed" }); @@ -562,6 +813,7 @@ const plugin = { api.registerHttpRoute({ path: "/loglife/verify/check", + auth: "plugin", handler: async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== "POST") { jsonResponse(res, 405, { error: "Method not allowed" }); @@ -622,6 +874,7 @@ const plugin = { api.registerHttpRoute({ path: "/loglife/register", + auth: "plugin", handler: async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== "POST") { jsonResponse(res, 405, { error: "Method not allowed" }); @@ -665,6 +918,14 @@ const plugin = { ); if (alreadyRegistered) { + // Keep runtime config in sync even for idempotent re-register calls. + const generated = generateConfig(usersConfig); + writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); + applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + // Re-resolve routing from current bindings instead of stale sticky assignment. + clearPeerAssignmentsForPhone(phone); + const existingUser = usersConfig.users.find((u) => hasMatchingIdentifier(u.identifiers, phone)); + if (existingUser) ensureUserWorkspaceAudioMetadataRule(existingUser.id); const linkCode = generateLinkCode(); upsertPendingLink({ code: linkCode, @@ -695,6 +956,8 @@ const plugin = { // We can't rely on $include because the gateway flattens it on hot-reload. applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + clearPeerAssignmentsForPhone(phone); + ensureUserWorkspaceAudioMetadataRule(userId); const linkCode = generateLinkCode(); upsertPendingLink({ @@ -719,6 +982,7 @@ const plugin = { api.registerHttpRoute({ path: "/loglife/unregister", + auth: "plugin", handler: async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== "POST") { jsonResponse(res, 405, { error: "Method not allowed" }); @@ -763,6 +1027,7 @@ const plugin = { const generated = generateConfig(usersConfig); writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); applyGeneratedConfigToOpenclaw(openclawJsonPath, generated); + cleanupUserRuntimeState(removedUserIds); pendingLinks.clear(); persistPendingLinks(); @@ -797,10 +1062,75 @@ const plugin = { }, }); + // --- POST /loglife/telegram/verify/send --- + + api.registerHttpRoute({ + path: "/loglife/telegram/verify/send", + auth: "plugin", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + let body: Record; + try { + body = await readBody(req); + } catch { + jsonResponse(res, 400, { error: "Invalid JSON body" }); + return; + } + + const peerRaw = body.phone as string | undefined; + if (!peerRaw || typeof peerRaw !== "string") { + jsonResponse(res, 400, { error: "Missing required field: phone" }); + return; + } + + const peer = normalizeTelegramPeer(peerRaw); + if (peer.length < 3) { + jsonResponse(res, 400, { error: "Invalid Telegram recipient" }); + return; + } + + const codeKey = `telegram:${peer}`; + const existing = telegramVerificationCodes.get(codeKey); + if (existing && Date.now() - existing.sentAt < VERIFY_COOLDOWN_MS) { + const retryIn = Math.ceil((VERIFY_COOLDOWN_MS - (Date.now() - existing.sentAt)) / 1000); + jsonResponse(res, 429, { error: `Too many requests. Try again in ${retryIn}s` }); + return; + } + + const code = String(randomInt(100_000, 999_999)); + telegramVerificationCodes.set(codeKey, { + code, + expiresAt: Date.now() + VERIFY_TTL_MS, + sentAt: Date.now(), + }); + + const message = `Your LogLife verification code is: ${code}`; + const result = await sendTelegramMessage(sendTG, peer, message); + + if (!result.ok) { + telegramVerificationCodes.delete(codeKey); + jsonResponse(res, 502, { error: result.error ?? "Failed to send message" }); + return; + } + + jsonResponse(res, 200, { sent: true }); + }, + }); + // --- GET /loglife/verify/status --- api.registerHttpRoute({ path: "/loglife/verify/status", + auth: "plugin", handler: async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== "GET") { jsonResponse(res, 405, { error: "Method not allowed" }); @@ -834,10 +1164,164 @@ const plugin = { }, }); + // --- POST /loglife/telegram/verify/check --- + + api.registerHttpRoute({ + path: "/loglife/telegram/verify/check", + auth: "plugin", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + let body: Record; + try { + body = await readBody(req); + } catch { + jsonResponse(res, 400, { error: "Invalid JSON body" }); + return; + } + + const peerRaw = body.phone as string | undefined; + const codeInput = body.code as string | undefined; + + if (!peerRaw || typeof peerRaw !== "string") { + jsonResponse(res, 400, { error: "Missing required field: phone" }); + return; + } + if (!codeInput || typeof codeInput !== "string") { + jsonResponse(res, 400, { error: "Missing required field: code" }); + return; + } + + const peer = normalizeTelegramPeer(peerRaw); + const codeKey = `telegram:${peer}`; + const entry = telegramVerificationCodes.get(codeKey); + + if (!entry || Date.now() > entry.expiresAt) { + telegramVerificationCodes.delete(codeKey); + jsonResponse(res, 200, { verified: false, error: "Code expired or not found" }); + return; + } + + if (!safeCompare(entry.code, codeInput.trim())) { + jsonResponse(res, 200, { verified: false, error: "Invalid code" }); + return; + } + + telegramVerificationCodes.delete(codeKey); + jsonResponse(res, 200, { verified: true }); + + sendTelegramMessage( + sendTG, + peer, + "Welcome to LogLife! Your dashboard is now connected. Send me a message anytime to start journaling.", + ).catch(() => { /* best-effort */ }); + }, + }); + + // --- GET /loglife/audio-metadata --- + + api.registerHttpRoute({ + path: "/loglife/audio-metadata", + auth: "plugin", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "GET") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const phoneRaw = url.searchParams.get("phone"); + const userIdRaw = url.searchParams.get("userId"); + + if (!phoneRaw && !userIdRaw) { + jsonResponse(res, 400, { error: "Provide ?phone= or ?userId=" }); + return; + } + + try { + const usersConfig = loadUsersJson(usersJsonPath); + let targetUser: UserProfile | undefined; + + if (userIdRaw) { + const userId = userIdRaw.trim(); + if (userId) { + targetUser = usersConfig.users.find((u) => u.id === userId); + } + } + + if (!targetUser && phoneRaw) { + const normalizedPhone = normalizePhone(phoneRaw); + targetUser = usersConfig.users.find((u) => + hasMatchingIdentifier(u.identifiers, normalizedPhone), + ); + } + + if (!targetUser) { + jsonResponse(res, 404, { error: "User not found" }); + return; + } + + const audioMetadataDir = join(stateDir, `workspace-${targetUser.id}`, "audio_metadata"); + if (!existsSync(audioMetadataDir)) { + jsonResponse(res, 200, { + userId: targetUser.id, + audioMetadata: {}, + count: 0, + }); + return; + } + + const files = await readdir(audioMetadataDir, { withFileTypes: true }); + const jsonFiles = files + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".json")) + .map((entry) => entry.name) + .sort(); + + const audioMetadata: Record = {}; + for (const fileName of jsonFiles) { + const filePath = join(audioMetadataDir, fileName); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(filePath, "utf-8")); + } catch { + parsed = null; + } + + const messageId = fileName.replace(/\.json$/i, ""); + audioMetadata[messageId] = parsed; + } + + jsonResponse(res, 200, { + userId: targetUser.id, + audioMetadata, + count: jsonFiles.length, + }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.error(`Failed to read audio metadata: ${errMsg}`); + jsonResponse(res, 500, { error: "Failed to read audio metadata" }); + } + }, + }); + // --- GET /loglife/users --- api.registerHttpRoute({ path: "/loglife/users", + auth: "plugin", handler: async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== "GET") { jsonResponse(res, 405, { error: "Method not allowed" }); @@ -863,6 +1347,179 @@ const plugin = { } }, }); + + // --- POST /loglife/telegram/register --- + + api.registerHttpRoute({ + path: "/loglife/telegram/register", + auth: "plugin", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + let body: Record; + try { + body = await readBody(req); + } catch { + jsonResponse(res, 400, { error: "Invalid JSON body" }); + return; + } + + const peerRaw = body.phone as string | undefined; + if (!peerRaw || typeof peerRaw !== "string") { + jsonResponse(res, 400, { error: "Missing required field: phone" }); + return; + } + + const peer = normalizeTelegramPeer(peerRaw); + if (peer.length < 3) { + jsonResponse(res, 400, { error: "Invalid Telegram recipient" }); + return; + } + + const telegramIdentifier = toTelegramIdentifier(peer); + const name = (body.name as string | undefined)?.trim() || undefined; + const model = (body.model as string | undefined)?.trim() || undefined; + + try { + const usersConfig = loadUsersJson(usersJsonPath); + + // Idempotent: check if telegram identifier is already registered + const targetIdentifiers = parseAllIdentifiers([telegramIdentifier]); + const alreadyRegistered = usersConfig.users.some((u) => + u.identifiers.some((id) => { + try { + const parsed = parseAllIdentifiers([id]); + return parsed.some((p) => + targetIdentifiers.some((ti) => ti.channel === p.channel && ti.peerId === p.peerId), + ); + } catch { + return false; + } + }), + ); + + if (alreadyRegistered) { + jsonResponse(res, 200, { registered: true, existing: true }); + return; + } + + const userId = deriveUserId(telegramIdentifier, name, usersConfig); + const newUser: UserProfile = { + id: userId, + identifiers: [telegramIdentifier], + }; + if (name) newUser.name = name; + if (model) newUser.model = model; + + usersConfig.users.push(newUser); + + mkdirSync(multiUserDir, { recursive: true }); + writeFileSync(usersJsonPath, JSON.stringify(usersConfig, null, 2) + "\n"); + + const generated = generateConfig(usersConfig); + writeFileSync(generatedJsonPath, JSON.stringify(generated, null, 2) + "\n"); + + if (existsSync(openclawJsonPath)) { + const now = new Date(); + utimesSync(openclawJsonPath, now, now); + } + ensureUserWorkspaceAudioMetadataRule(userId); + + api.logger.info(`Registered user "${userId}" (${telegramIdentifier})`); + jsonResponse(res, 200, { registered: true, userId }); + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + api.logger.error(`Registration failed for ${telegramIdentifier}: ${errMsg}`); + jsonResponse(res, 500, { error: "Registration failed" }); + } + }, + }); + + // --- POST /loglife/telegram/link/resolve --- + + api.registerHttpRoute({ + path: "/loglife/telegram/link/resolve", + auth: "plugin", + handler: async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST") { + jsonResponse(res, 405, { error: "Method not allowed" }); + return; + } + + if (!apiKey || !verifyApiKey(req, apiKey)) { + jsonResponse(res, 401, { error: "Unauthorized" }); + return; + } + + let body: Record; + try { + body = await readBody(req); + } catch { + jsonResponse(res, 400, { error: "Invalid JSON body" }); + return; + } + + const tokenRaw = body.token as string | undefined; + if (!tokenRaw || typeof tokenRaw !== "string") { + jsonResponse(res, 400, { error: "Missing required field: token" }); + return; + } + + const token = tokenRaw.trim().replace(/^ll_/, ""); + if (!token) { + jsonResponse(res, 400, { error: "Invalid token" }); + return; + } + const startMarker = `ll_${token}`; + + try { + const raw = await readFile(sessionsPath, "utf-8"); + const sessions: Record> = JSON.parse(raw); + + for (const [sessionKey, session] of Object.entries(sessions)) { + if (!sessionLikelyTelegram(session)) continue; + + const sessionFile = session.sessionFile; + if (typeof sessionFile !== "string" || !sessionFile) continue; + + let sessionContent = ""; + try { + sessionContent = await readFile(sessionFile, "utf-8"); + } catch { + continue; + } + + if (!sessionContent.includes(startMarker)) continue; + + const delivery = session.deliveryContext as Record | undefined; + const origin = session.origin as Record | undefined; + const chatId = + extractTelegramChatId(delivery?.to) + ?? extractTelegramChatId(session.lastTo) + ?? extractTelegramChatId(origin?.from) + ?? extractTelegramChatId(origin?.to) + ?? extractTelegramChatId(sessionKey); + + if (!chatId) continue; + + jsonResponse(res, 200, { found: true, chatId, sessionKey }); + return; + } + + jsonResponse(res, 404, { found: false, error: "Token not observed in Telegram sessions yet" }); + } catch { + jsonResponse(res, 500, { error: "Failed to resolve Telegram link token" }); + } + }, + }); }, }; diff --git a/plugin/package-lock.json b/plugin/package-lock.json index 810a49e7..3b192284 100644 --- a/plugin/package-lock.json +++ b/plugin/package-lock.json @@ -7,12 +7,8 @@ "": { "name": "loglife", "version": "0.1.0", - "dependencies": { - "ws": "^8.0.0" - }, "devDependencies": { "@types/node": "^22.0.0", - "@types/ws": "^8.0.0", "typescript": "^5.8.0", "vitest": "^4.0.0" } @@ -854,21 +850,10 @@ "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -1162,7 +1147,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1346,7 +1330,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -1510,27 +1493,6 @@ "engines": { "node": ">=8" } - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } } } } diff --git a/plugin/package.json b/plugin/package.json index ed2f3a4d..64f085b4 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -4,7 +4,7 @@ "description": "LogLife dashboard API plugin for OpenClaw", "type": "module", "openclaw": { - "extensions": ["."] + "extensions": ["./index.ts"] }, "scripts": { "test": "vitest run", diff --git a/scripts/generate-test-logs.js b/scripts/generate-test-logs.js new file mode 100644 index 00000000..cafa2c6d --- /dev/null +++ b/scripts/generate-test-logs.js @@ -0,0 +1,333 @@ +// scripts/generate-test-logs.js +// Usage: +// node scripts/generate-test-logs.js -> generates 700 entries (default) +// node scripts/generate-test-logs.js 500 -> generates 500 entries +// +// Output: website/data/test-logs.json + +const fs = require('fs'); +const path = require('path'); + +const OUT_DIR = path.join(__dirname, '..', 'website', 'data'); +const OUT_FILE = path.join(OUT_DIR, 'test-logs.json'); + +const arg = process.argv[2]; +const DEFAULT_COUNT = 700; +const MIN_COUNT = 500; +const MAX_COUNT = 1000; +let count = parseInt(arg, 10) || DEFAULT_COUNT; +if (count < MIN_COUNT) count = MIN_COUNT; +if (count > MAX_COUNT) count = MAX_COUNT; + +const categories = ['Work', 'Health', 'Relationships', 'Other']; +const tagsPool = ['meeting', 'gym', 'call', 'email', 'family', 'travel', 'finance', 'sleep', 'meditation', 'errand']; +const sampleTexts = [ + 'Morning workout at the gym', + 'Daily standup and planning meeting', + 'Reviewed PR and left comments', + 'Lunch with parents', + 'Went for a 5km run', + 'Scheduled dentist appointment', + 'Quick check-in with product team', + 'Worked on billing integration', + 'Watched a talk about habit formation', + 'Asked about weather for tomorrow', + 'Booked flights for conference', + 'Had deep work session (3 hours)', + 'Evening walk with partner', + 'Sent client proposal', + 'Random message: what is the time?' +]; + +const importanceLevels = ['Low', 'Medium', 'High', 'Critical']; +const now = Date.now(); +const MS_IN_DAY = 24*3600*1000; +const lookbackDays = 90; // generate over last 90 days + +// Goal metadata is embedded in some logs so the goals pages can be driven +// directly from test-logs.json (instead of a separate hard-coded source). +const goalDefinitions = [ + { + id: 'g1', + name: 'Go to gym', + description: 'Build strength and consistency', + why: 'Stay healthy and build discipline', + category: 'Health', + tags: ['gym', 'strength'], + startDate: '2026-01-02', + targetDate: '2026-06-01', + milestones: ['10 sessions', '20 sessions', '30 sessions'], + }, + { + id: 'g2', + name: 'Deep work habit', + description: '4 focused hours/day on core project', + why: 'Protect deep focus time to ship meaningful work', + category: 'Work', + tags: ['deep-work', 'focus'], + startDate: '2026-01-10', + targetDate: '2026-12-31', + milestones: ['10 deep-work blocks', '50 focused hours', '100 focused hours'], + }, + { + id: 'g3', + name: 'Nurture close relationships', + description: 'Consistent quality time with family and friends', + why: 'Stay connected with people who matter most', + category: 'Relationships', + tags: ['family', 'friends'], + startDate: '2026-01-05', + targetDate: '2026-12-31', + milestones: ['6 meaningful calls', '10 meaningful calls', '20 meaningful calls'], + }, +]; + +function pick(arr){ return arr[Math.floor(Math.random()*arr.length)]; } +function randInt(min, max){ return Math.floor(Math.random()*(max-min+1))+min; } +function pad(n){ return n < 10 ? '0'+n : ''+n; } +function isoDate(ts){ return new Date(ts).toISOString(); } +function yyyyMmDd(ts){ + const d = new Date(ts); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; +} +function hhmm(ts){ + const d = new Date(ts); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +function choiceWeighted(weights){ + // weights: [{item, weight}, ...] + const total = weights.reduce((s,w)=>s+w.weight,0); + let r = Math.random()*total; + for(const w of weights){ + if(r < w.weight) return w.item; + r -= w.weight; + } + return weights[weights.length-1].item; +} + +function isoDateOnly(ts){ + return yyyyMmDd(ts); +} + +function addDays(isoDateString, days){ + const d = new Date(`${isoDateString}T12:00:00.000Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +function pickGoalForCategories(categoriesArr){ + const primary = categoriesArr.find((c) => c !== 'Other'); + if (!primary) return null; + return goalDefinitions.find((g) => g.category === primary) || null; +} + +function inferGoalValue(text, eventType){ + if (eventType !== 'session') return null; + + const kmMatch = text.match(/(\d+)\s*km/i); + if (kmMatch) { + return { km: Number(kmMatch[1]) }; + } + + const hoursMatch = text.match(/(\d+)\s*hours?/i); + if (hoursMatch) { + return { hours: Number(hoursMatch[1]) }; + } + + return null; +} + +function clamp(n, min, max){ + return Math.max(min, Math.min(max, n)); +} + +function daysBetween(isoA, isoB){ + const a = new Date(`${isoA}T12:00:00.000Z`).getTime(); + const b = new Date(`${isoB}T12:00:00.000Z`).getTime(); + return Math.round((b - a) / (24 * 3600 * 1000)); +} + +function inferGoalContribution(text, eventType){ + if (eventType === 'note') return 'note'; + if (eventType === 'milestone') return 'major'; + + const lower = text.toLowerCase(); + if (/\d+\s*km/.test(lower) || /\d+\s*hours?/.test(lower)) return 'major'; + if ( + lower.includes('deep work') || + lower.includes('proposal') || + lower.includes('workout') || + lower.includes('run') || + lower.includes('call') + ) { + return 'major'; + } + + return 'minor'; +} + +function inferProgressPercent(entryDate, goalStartDate, goalTargetDate){ + const totalDays = Math.max(1, daysBetween(goalStartDate, goalTargetDate)); + const elapsedDays = clamp(daysBetween(goalStartDate, entryDate), 0, totalDays); + const base = (elapsedDays / totalDays) * 100; + const jitter = randInt(-8, 8); + return clamp(Math.round(base + jitter), 0, 100); +} + +function maybeGoalData(ts, categoriesArr, text, tags, logType){ + // Keep goal metadata mostly on life logs to mirror real usage. + if (logType !== 'Life Log') return null; + if (Math.random() > 0.62) return null; + + const goal = pickGoalForCategories(categoriesArr); + if (!goal) return null; + + const goalEventType = choiceWeighted([ + { item: 'session', weight: 70 }, + { item: 'note', weight: 20 }, + { item: 'milestone', weight: 10 }, + ]); + + const entryDate = isoDateOnly(ts); + const goalStartDate = goal.startDate; + const goalTargetDate = goal.targetDate; + + const goalEventValue = inferGoalValue(text, goalEventType); + const goalContribution = inferGoalContribution(text, goalEventType); + const goalProgressValue = inferProgressPercent(entryDate, goalStartDate, goalTargetDate); + const mergedTags = Array.from(new Set([...goal.tags, ...tags])); + + const shouldAttachMilestoneMeta = goalEventType === 'milestone'; + const pickedMilestoneTitle = shouldAttachMilestoneMeta ? pick(goal.milestones) : undefined; + const goalMilestoneDate = shouldAttachMilestoneMeta + ? addDays(entryDate, randInt(-20, 40)) + : undefined; + const goalMilestoneId = shouldAttachMilestoneMeta + ? `${goal.id}-m-${entryDate}-${randInt(100, 999)}` + : undefined; + const goalMilestoneIsUpcoming = shouldAttachMilestoneMeta + ? goalMilestoneDate > entryDate + : undefined; + + return { + goalId: goal.id, + goalName: goal.name, + goalDescription: goal.description, + goalWhy: goal.why, + goalCategory: goal.category, + goalTags: mergedTags, + goalStartDate, + goalTargetDate, + goalProgressValue, + goalEventType, + goalEventValue, + goalContribution, + goalMilestoneId, + goalMilestoneDate, + goalMilestoneTitle: pickedMilestoneTitle, + goalMilestoneIsUpcoming, + }; +} + +// Build an array of timestamps with bursts +function buildTimestamps(n){ + const arr = []; + // pick some burst days + const numBursts = Math.max(1, Math.floor(n / 200)); // ~1 burst per 200 items + const burstDays = new Set(); + for(let i=0;i Math.random() - 0.5).sort((a,b)=>a-b); +} + +function sampleTags(){ + const out = []; + const take = Math.random() < 0.4 ? 0 : randInt(1,3); + for(let i=0;ic!==baseCat)); + return [baseCat, other]; + } + return [baseCat]; +} + +// generate +const timestamps = buildTimestamps(count); +const logs = timestamps.map((ts, idx) => { + const baseText = pick(sampleTexts); + // sometimes append small random detail + const detailChance = Math.random(); + let text = baseText; + if(detailChance < 0.2) text += ` — ${pick(['quick note','follow-up','left a comment','rescheduled','short call'])}`; + if(detailChance > 0.95) text = `User: "${pick(['what time is it?', 'can you remind me tomorrow', 'ping', 'who is online?'])}"`; // noise + + const category = pick(categories); + const categoriesArr = maybeMultiCategory(category); + const tags = sampleTags(); + + // type: Life Log 80% / Ignored 20% + const type = Math.random() < 0.8 ? 'Life Log' : 'Ignored'; + + // importance: Low most of the time, small chance of High/Critical + const importance = choiceWeighted([ + { item: 'Low', weight: 70 }, + { item: 'Medium', weight: 20 }, + { item: 'High', weight: 8 }, + { item: 'Critical', weight: 2 }, + ]); + + // occasionally include session info (20%) + const sessionId = Math.random() < 0.2 ? `sess_${randInt(1000,9999)}` : null; + const goalData = maybeGoalData(ts, categoriesArr, text, tags, type); + + return { + id: `log_${idx + 1}`, + timestamp: isoDate(ts), + date: yyyyMmDd(ts), + time: hhmm(ts), + text, + categories: categoriesArr, + tags, + type, + importance: Math.random() < 0.05 ? importance : undefined, // keep importance sparse + sessionId: sessionId || undefined, + source: sessionId ? 'whatsapp' : (Math.random() < 0.05 ? 'email' : undefined), + ...(goalData || {}), + }; +}); + +// ensure output dir exists +fs.mkdirSync(OUT_DIR, { recursive: true }); +fs.writeFileSync(OUT_FILE, JSON.stringify(logs, null, 2), 'utf8'); + +console.log(`Generated ${logs.length} test logs → ${OUT_FILE}`); \ No newline at end of file diff --git a/website/app/account/page.tsx b/website/app/account/page.tsx index d82a74b1..ade72142 100644 --- a/website/app/account/page.tsx +++ b/website/app/account/page.tsx @@ -24,13 +24,24 @@ export default function AccountPage() { const [passwordLoading, setPasswordLoading] = useState(false); const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [disconnectingWhatsApp, setDisconnectingWhatsApp] = useState(false); + const [telegramLoading, setTelegramLoading] = useState(false); + const [telegramFeedback, setTelegramFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [telegramLink, setTelegramLink] = useState<{ token: string; deepLink: string } | null>(null); + const [developerSettingsEnabled, setDeveloperSettingsEnabled] = useState(false); + const [developerSettingsLoading, setDeveloperSettingsLoading] = useState(false); + const [developerSettingsMessage, setDeveloperSettingsMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const whatsappPhone = (user?.unsafeMetadata as Record | undefined)?.whatsappPhone || ""; + const telegramChatId = (user?.unsafeMetadata as Record | undefined)?.telegramChatId || ""; const whatsAppConnected = !!whatsappPhone; + const telegramConnected = !!telegramChatId; React.useEffect(() => { if (user) { setFirstName(user.firstName || ""); setLastName(user.lastName || ""); + setDeveloperSettingsEnabled( + Boolean((user.unsafeMetadata as Record | undefined)?.developerSettingsEnabled) + ); } }, [user]); @@ -145,6 +156,110 @@ export default function AccountPage() { } }; + const handleTelegramStartConnect = async () => { + setTelegramLoading(true); + setTelegramFeedback(null); + try { + const res = await fetch("/api/telegram/link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "create" }), + }); + const data = await res.json(); + if (!res.ok || !data.deepLink || !data.token) { + setTelegramFeedback({ type: "error", text: data.error || "Failed to create Telegram link" }); + return; + } + + setTelegramLink({ token: data.token, deepLink: data.deepLink }); + window.open(data.deepLink, "_blank", "noopener,noreferrer"); + setTelegramFeedback({ + type: "success", + text: "Telegram opened. Press Start, then click 'I've Pressed Start'.", + }); + } catch { + setTelegramFeedback({ type: "error", text: "Network error. Please try again." }); + } finally { + setTelegramLoading(false); + } + }; + + const handleTelegramCompleteConnect = async () => { + if (!telegramLink?.token) return; + setTelegramLoading(true); + setTelegramFeedback(null); + try { + const res = await fetch("/api/telegram/link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "complete", + token: telegramLink.token, + }), + }); + const data = await res.json(); + if (!res.ok || !data.linked) { + if (res.status === 409 && data.pending) { + setTelegramFeedback({ + type: "error", + text: "Not detected yet. Press Start in Telegram bot and try again in a few seconds.", + }); + return; + } + setTelegramFeedback({ type: "error", text: data.error || "Failed to link Telegram" }); + return; + } + setTelegramFeedback({ type: "success", text: "Telegram connected successfully." }); + setTelegramLink(null); + await user!.reload(); + } catch { + setTelegramFeedback({ type: "error", text: "Network error. Please try again." }); + } finally { + setTelegramLoading(false); + } + }; + + const handleTelegramDisconnect = async () => { + try { + const metadata = (user!.unsafeMetadata ?? {}) as Record; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { telegramChatId: _removedChatId, telegramPendingLink: _removedLink, ...rest } = metadata; + await user!.update({ unsafeMetadata: rest }); + setTelegramLink(null); + setTelegramFeedback(null); + } catch { + alert("Failed to disconnect Telegram. Please try again."); + } + }; + + const handleToggleDeveloperSettings = async () => { + const nextValue = !developerSettingsEnabled; + setDeveloperSettingsLoading(true); + setDeveloperSettingsMessage(null); + + try { + const metadata = (user!.unsafeMetadata ?? {}) as Record; + await user!.update({ + unsafeMetadata: { + ...metadata, + developerSettingsEnabled: nextValue, + }, + }); + setDeveloperSettingsEnabled(nextValue); + setDeveloperSettingsMessage({ + type: "success", + text: `Developer settings turned ${nextValue ? "on" : "off"}.`, + }); + } catch { + setDeveloperSettingsMessage({ + type: "error", + text: "Failed to update developer settings. Please try again.", + }); + } finally { + setDeveloperSettingsLoading(false); + } + }; + function maskPhone(phone: string): string { if (phone.length <= 4) return phone; const last4 = phone.slice(-4); @@ -152,6 +267,13 @@ export default function AccountPage() { return prefix + last4; } + function maskId(id: string): string { + if (id.length <= 4) return id; + const last4 = id.slice(-4); + const prefix = id.slice(0, id.length - 4).replace(/./g, "*"); + return prefix + last4; + } + const primaryEmail = user.emailAddresses.find( (email) => email.id === user.primaryEmailAddressId ); @@ -159,16 +281,6 @@ export default function AccountPage() { return (
- {/* Breadcrumb */} -
- - - - - Back to Dashboard - -
- {/* Header */}

Account Settings

@@ -340,6 +452,51 @@ export default function AccountPage() {
+ {/* Developer Settings */} +
+
+

Developer Settings

+
+
+
+
+

Enable developer settings

+

+ Turn this on to enable developer settings. +

+
+ +
+ {developerSettingsMessage && ( +
+ {developerSettingsMessage.text} +
+ )} +
+
+ {/* Connected Accounts */}
@@ -412,7 +569,82 @@ export default function AccountPage() { )}
- {user.externalAccounts.length === 0 && !whatsAppConnected && ( + {/* Telegram Connection */} +
+
+
+ + + +
+ Telegram + {telegramConnected && ( + {maskId(telegramChatId)} + )} +
+
+ {telegramConnected ? ( +
+ + Verified + + +
+ ) : ( + + )} +
+ + {!telegramConnected && telegramLink && ( +
+

+ Press Start in Telegram bot, then confirm below. +

+
+ + + Open Telegram bot again + +
+
+ )} + + {telegramFeedback && !telegramConnected && ( +
+ {telegramFeedback.text} +
+ )} +
+ + {user.externalAccounts.length === 0 && !whatsAppConnected && !telegramConnected && (

No connected accounts yet

)}
diff --git a/website/app/api/audio-metadata/route.ts b/website/app/api/audio-metadata/route.ts new file mode 100644 index 00000000..d9d97c36 --- /dev/null +++ b/website/app/api/audio-metadata/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; + +const OPENCLAW_API_URL = process.env.OPENCLAW_API_URL; +const OPENCLAW_API_KEY = process.env.OPENCLAW_API_KEY; + +type OpenClawUser = { + id?: string; + identifiers?: string[]; +}; + +function toDigits(value: string): string { + return value.replace(/[^0-9]/g, ""); +} + +function identifierMatchesPhone(identifier: string, phoneDigits: string): boolean { + const trimmed = identifier.trim(); + if (!trimmed) return false; + const payload = trimmed.includes(":") ? trimmed.slice(trimmed.indexOf(":") + 1) : trimmed; + const base = payload.split("@")[0] ?? payload; + const digits = toDigits(base); + return digits.length > 0 && digits === phoneDigits; +} + +async function tryResolveUserIdByPhone(phone: string): Promise { + const phoneDigits = toDigits(phone); + if (!phoneDigits) return null; + + const usersResponse = await fetch(`${OPENCLAW_API_URL}/loglife/users`, { + headers: { Authorization: `Bearer ${OPENCLAW_API_KEY}` }, + }); + if (!usersResponse.ok) return null; + + const usersData = (await usersResponse.json()) as { users?: OpenClawUser[] }; + const users = Array.isArray(usersData.users) ? usersData.users : []; + const matched = users.find((user) => + Array.isArray(user.identifiers) && + user.identifiers.some((identifier) => identifierMatchesPhone(identifier, phoneDigits)), + ); + return matched?.id ?? null; +} + +export async function GET(req: NextRequest) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!OPENCLAW_API_URL || !OPENCLAW_API_KEY) { + return NextResponse.json( + { error: "Server not configured: missing OPENCLAW_API_URL or OPENCLAW_API_KEY" }, + { status: 503 }, + ); + } + + const phone = req.nextUrl.searchParams.get("phone"); + const userIdParam = req.nextUrl.searchParams.get("userId"); + + if (!phone && !userIdParam) { + return NextResponse.json({ error: "Provide ?phone= or ?userId=" }, { status: 400 }); + } + + const params = new URLSearchParams(); + if (phone) params.set("phone", phone); + if (userIdParam) params.set("userId", userIdParam); + + try { + let response = await fetch(`${OPENCLAW_API_URL}/loglife/audio-metadata?${params}`, { + headers: { Authorization: `Bearer ${OPENCLAW_API_KEY}` }, + }); + + // Fallback for identifiers that don't match strict phone parsing in upstream. + if (response.status === 404 && phone) { + const resolvedUserId = await tryResolveUserIdByPhone(phone); + if (resolvedUserId) { + const fallbackParams = new URLSearchParams(); + fallbackParams.set("userId", resolvedUserId); + response = await fetch(`${OPENCLAW_API_URL}/loglife/audio-metadata?${fallbackParams}`, { + headers: { Authorization: `Bearer ${OPENCLAW_API_KEY}` }, + }); + } + } + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) { + const bodyPreview = (await response.text()).slice(0, 200); + return NextResponse.json( + { + error: "OpenClaw returned non-JSON response for /loglife/audio-metadata", + upstreamStatus: response.status, + upstreamContentType: contentType || null, + upstreamBodyPreview: bodyPreview || null, + }, + { status: 502 }, + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } +} diff --git a/website/app/api/support/route.ts b/website/app/api/support/route.ts new file mode 100644 index 00000000..5cbfbf40 --- /dev/null +++ b/website/app/api/support/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Resend } from "resend"; + +// Resend's onboarding@resend.dev can only send to your Resend account email. +// Set SUPPORT_EMAIL in .env.local to that address for testing, or verify a domain +// and use a custom "from" to send to any address. +const SUPPORT_EMAIL = process.env.SUPPORT_EMAIL ?? "hafizahtasham07@gmail.com"; + +const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; // 5 MB + +export async function POST(req: NextRequest) { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "Email service not configured" }, + { status: 500 }, + ); + } + const resend = new Resend(apiKey); + + const contentType = req.headers.get("content-type") ?? ""; + let type: string | undefined; + let subject: string | undefined; + let email: string | undefined; + let message: string | undefined; + let attachment: { filename: string; content: Buffer } | null = null; + + if (contentType.includes("multipart/form-data")) { + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); + } + + type = (formData.get("type") as string) ?? undefined; + subject = (formData.get("subject") as string) ?? undefined; + email = (formData.get("email") as string) ?? undefined; + message = (formData.get("message") as string) ?? undefined; + + const file = formData.get("attachment"); + if (file instanceof File && file.size > 0) { + if (file.size > MAX_ATTACHMENT_BYTES) { + return NextResponse.json( + { error: "Attachment must be under 5 MB" }, + { status: 400 }, + ); + } + const arrayBuffer = await file.arrayBuffer(); + attachment = { + filename: file.name, + content: Buffer.from(arrayBuffer), + }; + } + } else { + try { + const body = await req.json(); + type = body.type; + subject = body.subject; + email = body.email; + message = body.message; + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + } + + if (!message?.trim()) { + return NextResponse.json({ error: "Message is required" }, { status: 400 }); + } + + try { + await resend.emails.send({ + from: "LogLife Support ", + to: SUPPORT_EMAIL, + subject: `[${type ?? "General"}] ${subject ?? "No subject"}`, + text: [ + `Type: ${type ?? "—"}`, + `From: ${email ?? "—"}`, + "", + message, + ].join("\n"), + ...(attachment && { attachments: [attachment] }), + }); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json({ error: "Failed to send message" }, { status: 500 }); + } +} diff --git a/website/app/api/telegram/link/route.ts b/website/app/api/telegram/link/route.ts new file mode 100644 index 00000000..c2c34577 --- /dev/null +++ b/website/app/api/telegram/link/route.ts @@ -0,0 +1,192 @@ +import { randomBytes, createHash, timingSafeEqual } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { auth, clerkClient } from "@clerk/nextjs/server"; + +const OPENCLAW_API_URL = process.env.OPENCLAW_API_URL; +const OPENCLAW_API_KEY = process.env.OPENCLAW_API_KEY; +const TELEGRAM_BOT_USERNAME = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME; +const LINK_TTL_MS = 10 * 60 * 1000; + +type LinkMeta = { + tokenHash: string; + expiresAt: number; + createdAt: number; +}; + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function safeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex")); + } catch { + return false; + } +} + +export async function POST(req: NextRequest) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { action?: "create" | "complete"; token?: string; chatId?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const action = body.action; + if (!action) { + return NextResponse.json({ error: "Missing required field: action" }, { status: 400 }); + } + + const client = await clerkClient(); + const user = await client.users.getUser(userId); + const unsafe = (user.unsafeMetadata ?? {}) as Record; + + if (action === "create") { + if (!TELEGRAM_BOT_USERNAME) { + return NextResponse.json( + { error: "Server not configured: missing NEXT_PUBLIC_TELEGRAM_BOT_USERNAME" }, + { status: 503 }, + ); + } + + const token = randomBytes(24).toString("base64url"); + const now = Date.now(); + const linkMeta: LinkMeta = { + tokenHash: hashToken(token), + createdAt: now, + expiresAt: now + LINK_TTL_MS, + }; + + await client.users.updateUser(userId, { + unsafeMetadata: { + ...unsafe, + telegramPendingLink: linkMeta, + }, + }); + + const startPayload = `ll_${token}`; + const deepLink = `https://t.me/${TELEGRAM_BOT_USERNAME}?start=${encodeURIComponent(startPayload)}`; + const webDeepLink = `https://web.telegram.org/a/#@${TELEGRAM_BOT_USERNAME}?start=${encodeURIComponent(startPayload)}`; + + return NextResponse.json({ + ok: true, + deepLink, + webDeepLink, + token, + expiresInSec: Math.floor(LINK_TTL_MS / 1000), + note: "Open the link and press Start in Telegram, then complete linking from dashboard.", + }); + } + + if (action === "complete") { + if (!OPENCLAW_API_URL || !OPENCLAW_API_KEY) { + return NextResponse.json( + { error: "Server not configured: missing OPENCLAW_API_URL or OPENCLAW_API_KEY" }, + { status: 503 }, + ); + } + + const token = body.token?.trim(); + let chatId = body.chatId?.trim() || ""; + if (!token) { + return NextResponse.json({ error: "Missing required field: token" }, { status: 400 }); + } + + const pending = unsafe.telegramPendingLink as LinkMeta | undefined; + if (!pending?.tokenHash || !pending.expiresAt) { + return NextResponse.json({ error: "No pending Telegram link request. Create one first." }, { status: 400 }); + } + + if (Date.now() > pending.expiresAt) { + return NextResponse.json({ error: "Link token expired. Create a new link." }, { status: 400 }); + } + + const providedHash = hashToken(token); + if (!safeEqualHex(pending.tokenHash, providedHash)) { + return NextResponse.json({ error: "Invalid link token." }, { status: 400 }); + } + + if (!chatId) { + try { + const resolveResponse = await fetch(`${OPENCLAW_API_URL}/loglife/telegram/link/resolve`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token }), + }); + + if (resolveResponse.ok) { + const resolved = await resolveResponse.json(); + chatId = String(resolved.chatId ?? "").trim(); + } else if (resolveResponse.status === 404) { + return NextResponse.json( + { pending: true, error: "Telegram start not detected yet. Press Start in bot and try again." }, + { status: 409 }, + ); + } else { + const resolveError = await resolveResponse.text(); + return NextResponse.json( + { error: `Failed to resolve Telegram chat: ${resolveError || "Unknown error"}` }, + { status: 502 }, + ); + } + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } + } + + if (!chatId) { + return NextResponse.json({ error: "Unable to resolve Telegram chatId from token." }, { status: 400 }); + } + + const name = [user.firstName, user.lastName].filter(Boolean).join(" ") || undefined; + let registerOk = true; + let registerError = ""; + try { + const response = await fetch(`${OPENCLAW_API_URL}/loglife/telegram/register`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ phone: chatId, name }), + }); + + if (!response.ok) { + registerOk = false; + registerError = await response.text(); + } + } catch { + registerOk = false; + registerError = "Failed to reach OpenClaw server"; + } + + if (!registerOk) { + return NextResponse.json( + { error: `Telegram register failed: ${registerError || "Unknown error"}` }, + { status: 502 }, + ); + } + + await client.users.updateUser(userId, { + unsafeMetadata: { + ...unsafe, + telegramChatId: chatId, + telegramPendingLink: null, + }, + }); + + return NextResponse.json({ linked: true, chatId }); + } + + return NextResponse.json({ error: "Invalid action. Use 'create' or 'complete'" }, { status: 400 }); +} diff --git a/website/app/api/telegram/register/route.ts b/website/app/api/telegram/register/route.ts new file mode 100644 index 00000000..32bf5e0f --- /dev/null +++ b/website/app/api/telegram/register/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth, clerkClient } from "@clerk/nextjs/server"; + +const OPENCLAW_API_URL = process.env.OPENCLAW_API_URL; +const OPENCLAW_API_KEY = process.env.OPENCLAW_API_KEY; + +export async function POST(req: NextRequest) { + if (!OPENCLAW_API_URL || !OPENCLAW_API_KEY) { + return NextResponse.json( + { error: "Server not configured: missing OPENCLAW_API_URL or OPENCLAW_API_KEY" }, + { status: 503 }, + ); + } + + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { phone?: string; chatId?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const chatId = body.chatId?.trim() || body.phone?.trim(); + if (!chatId) { + return NextResponse.json({ error: "Missing required field: chatId (or phone)" }, { status: 400 }); + } + + const client = await clerkClient(); + const user = await client.users.getUser(userId); + const name = [user.firstName, user.lastName].filter(Boolean).join(" ") || undefined; + + try { + const response = await fetch(`${OPENCLAW_API_URL}/loglife/telegram/register`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ phone: chatId, name }), + }); + + const data = response.ok ? await response.json() : { error: await response.text() }; + return NextResponse.json(data, { status: response.status }); + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } +} diff --git a/website/app/api/telegram/verify/route.ts b/website/app/api/telegram/verify/route.ts new file mode 100644 index 00000000..07f32ee0 --- /dev/null +++ b/website/app/api/telegram/verify/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth, clerkClient } from "@clerk/nextjs/server"; + +const OPENCLAW_API_URL = process.env.OPENCLAW_API_URL; +const OPENCLAW_API_KEY = process.env.OPENCLAW_API_KEY; + +export async function POST(req: NextRequest) { + if (!OPENCLAW_API_URL || !OPENCLAW_API_KEY) { + return NextResponse.json( + { error: "Server not configured: missing OPENCLAW_API_URL or OPENCLAW_API_KEY" }, + { status: 503 }, + ); + } + + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { action?: string; phone?: string; chatId?: string; code?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { action, code } = body; + const chatId = body.chatId?.trim() || body.phone?.trim(); + + if (!action || !chatId) { + return NextResponse.json({ error: "Missing required fields: action, chatId (or phone)" }, { status: 400 }); + } + + if (action === "send") { + try { + const response = await fetch(`${OPENCLAW_API_URL}/loglife/telegram/verify/send`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ phone: chatId }), + }); + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } + } + + if (action === "check") { + if (!code) { + return NextResponse.json({ error: "Missing required field: code" }, { status: 400 }); + } + + try { + const response = await fetch(`${OPENCLAW_API_URL}/loglife/telegram/verify/check`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENCLAW_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ phone: chatId, code }), + }); + + const data = await response.json(); + + if (data.verified) { + const client = await clerkClient(); + const user = await client.users.getUser(userId); + await client.users.updateUser(userId, { + unsafeMetadata: { ...user.unsafeMetadata, telegramChatId: chatId }, + }); + } + + return NextResponse.json(data, { status: response.status }); + } catch { + return NextResponse.json({ error: "Failed to reach OpenClaw server" }, { status: 502 }); + } + } + + return NextResponse.json({ error: "Invalid action. Use 'send' or 'check'" }, { status: 400 }); +} diff --git a/website/app/call/[number]/[token]/page.tsx b/website/app/call/[number]/[token]/page.tsx index d8e7961f..cb391a81 100644 --- a/website/app/call/[number]/[token]/page.tsx +++ b/website/app/call/[number]/[token]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useRef, Suspense } from "react"; import { useParams } from "next/navigation"; -import { useWhatsAppWidget } from "../../../contexts/WhatsAppWidgetContext"; +import { useWhatsAppWidget } from "@/contexts/WhatsAppWidgetContext"; import Vapi from "@vapi-ai/web"; function CallPageContent() { diff --git a/website/app/components/Navbar.tsx b/website/app/components/Navbar.tsx deleted file mode 100644 index a467f0b4..00000000 --- a/website/app/components/Navbar.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; -import Link from "next/link"; -import Image from "next/image"; -import { useState } from "react"; -import { usePathname } from "next/navigation"; -import { useWhatsAppWidget } from "../contexts/WhatsAppWidgetContext"; - -export default function Navbar() { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const { openWidget } = useWhatsAppWidget(); - const pathname = usePathname(); - - if (pathname === "/call") { - return null; - } - - return ( -
-
-
- -
- LogLife -
- - - - -
- - -
-
- - {/* Mobile Menu */} - {isMenuOpen && ( -
- setIsMenuOpen(false)} - > - How it works - - setIsMenuOpen(false)} - > - Pricing - - setIsMenuOpen(false)} - > - Blog - - -
- )} -
-
- ); -} diff --git a/website/app/dashboard/page.tsx b/website/app/dashboard/page.tsx index c77fc8ad..c668da79 100644 --- a/website/app/dashboard/page.tsx +++ b/website/app/dashboard/page.tsx @@ -3,7 +3,16 @@ import { useUser, useClerk } from "@clerk/nextjs"; import { useRouter } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; +import TodayOverview from "@/components/dashboard/TodayOverview"; +import HabitHeatmap from "@/components/dashboard/HabitHeatmap"; +import GoalsSection from "@/components/dashboard/GoalsSection"; +import NonDeveloperTodayOverview from "@/components/dashboard/nonDeveloper/NonDeveloperTodayOverview"; +import NonDeveloperHabitHeatmap from "@/components/dashboard/nonDeveloper/NonDeveloperHabitHeatmap"; +import LegacyTodayOverview from "@/components/dashboard/legacy/LegacyTodayOverview"; +import LegacyHabitHeatmap from "@/components/dashboard/legacy/LegacyHabitHeatmap"; +import LegacyGoalsSection from "@/components/dashboard/legacy/LegacyGoalsSection"; import { useState, useRef, useEffect, useCallback } from "react"; +import { useDemoMode } from "@/hooks/useDemoMode"; interface WhatsAppSession { sessionKey?: string; @@ -21,6 +30,25 @@ interface WhatsAppSession { model?: string; } +interface AudioMetadataResponse { + userId?: string; + audioMetadata?: Record; + count?: number; + error?: string; +} + +interface AudioMetadataItem { + messageId: string; + sourcePath: string | null; + sourceFileName: string | null; + transcription: string; + durationSeconds: number | null; + format: string | null; + sizeBytes: number | null; + modified: string | null; + modifiedMs: number | null; +} + function formatRelativeTime(timestamp: number | undefined | null): string { if (timestamp == null || timestamp === 0) return "never"; const now = Date.now(); @@ -67,6 +95,40 @@ function formatCountdown(totalSeconds: number): string { return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; } +function formatDurationLabel(seconds: number | null): string { + if (seconds == null || Number.isNaN(seconds)) return "Unknown"; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainderSeconds = seconds % 60; + if (minutes < 60) return `${minutes}m ${remainderSeconds}s`; + const hours = Math.floor(minutes / 60); + const remainderMinutes = minutes % 60; + return `${hours}h ${remainderMinutes}m`; +} + +function formatBytesLabel(bytes: number | null): string { + if (bytes == null || Number.isNaN(bytes)) return "Unknown"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatDateTimeLabel(isoString: string | null): string { + if (!isoString) return "Unknown"; + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) return "Unknown"; + return date.toLocaleString(); +} + +function shortMessageId(id: string): string { + if (id.length <= 18) return id; + return `${id.slice(0, 8)}...${id.slice(-6)}`; +} + +function toIsoDateFromMs(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + export default function DashboardPage() { const { user, isLoaded } = useUser(); const { signOut } = useClerk(); @@ -75,6 +137,7 @@ export default function DashboardPage() { const [session, setSession] = useState(null); const [sessionLoading, setSessionLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [compactMode, setCompactMode] = useState(false); const menuRef = useRef(null); const [countryCode, setCountryCode] = useState("1"); @@ -82,20 +145,32 @@ export default function DashboardPage() { const [verifyStep, setVerifyStep] = useState<"phone" | "message">("phone"); const [linkCode, setLinkCode] = useState(""); const [pollUntil, setPollUntil] = useState(null); - const [pollNow, setPollNow] = useState(Date.now()); + const [, setPollNow] = useState(Date.now()); const [copiedCode, setCopiedCode] = useState(false); const [verifyLoading, setVerifyLoading] = useState(false); const [verifyFeedback, setVerifyFeedback] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [audioMetadata, setAudioMetadata] = useState>({}); + const [audioMetadataCount, setAudioMetadataCount] = useState(0); + const [audioMetadataLoading, setAudioMetadataLoading] = useState(false); + const [audioMetadataError, setAudioMetadataError] = useState(null); + const { isDemoMode, toggleDemoMode } = useDemoMode(); const whatsappPhone = (user?.unsafeMetadata as Record | undefined)?.whatsappPhone || ""; const waTarget = normalizeWaMeTarget(process.env.NEXT_PUBLIC_LOGLIFE_WHATSAPP_NUMBER); const fullPhone = `${countryCode}${phoneLocal}`; const fullPhoneDisplay = `+${countryCode}${phoneLocal}`; + const developerSettingsEnabled = Boolean( + (user?.unsafeMetadata as Record | undefined)?.developerSettingsEnabled + ); + const isWhatsAppConnected = Boolean(whatsappPhone); + const countdownSeconds = pollUntil ? Math.max(0, Math.ceil((pollUntil - Date.now()) / 1000)) : 0; const fetchSession = useCallback((isRefresh = false) => { - if (!whatsappPhone) { + // Non-developer dashboard does not render session internals. + if (!developerSettingsEnabled || !whatsappPhone) { setSession(null); setSessionLoading(false); + setRefreshing(false); return; } if (isRefresh) setRefreshing(true); @@ -106,10 +181,46 @@ export default function DashboardPage() { .then((data) => { if (!data.error) setSession(data); else setSession(null); }) .catch(() => { setSession(null); }) .finally(() => { setSessionLoading(false); setRefreshing(false); }); - }, [whatsappPhone]); + }, [whatsappPhone, developerSettingsEnabled]); useEffect(() => { fetchSession(); }, [fetchSession]); + const fetchAudioMetadata = useCallback(async () => { + if (!whatsappPhone || developerSettingsEnabled) { + setAudioMetadata({}); + setAudioMetadataCount(0); + setAudioMetadataLoading(false); + setAudioMetadataError(null); + return; + } + + setAudioMetadataLoading(true); + setAudioMetadataError(null); + try { + const response = await fetch(`/api/audio-metadata?phone=${encodeURIComponent(whatsappPhone)}`); + const data = (await response.json()) as AudioMetadataResponse; + if (!response.ok || data.error) { + setAudioMetadata({}); + setAudioMetadataCount(0); + setAudioMetadataError(data.error || "Failed to load audio metadata"); + return; + } + + setAudioMetadata(data.audioMetadata ?? {}); + setAudioMetadataCount(typeof data.count === "number" ? data.count : Object.keys(data.audioMetadata ?? {}).length); + } catch { + setAudioMetadata({}); + setAudioMetadataCount(0); + setAudioMetadataError("Failed to load audio metadata"); + } finally { + setAudioMetadataLoading(false); + } + }, [whatsappPhone, developerSettingsEnabled]); + + useEffect(() => { + void fetchAudioMetadata(); + }, [fetchAudioMetadata]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) setMenuOpen(false); @@ -189,6 +300,269 @@ export default function DashboardPage() { router.push("/"); }; + const audioMetadataItems: AudioMetadataItem[] = Object.entries(audioMetadata) + .map(([messageId, value]) => { + const record = (value && typeof value === "object") ? value as Record : {}; + const sourcePath = typeof record.source_path === "string" ? record.source_path : null; + const transcription = typeof record.transcription === "string" ? record.transcription : ""; + const durationSeconds = typeof record.duration_seconds === "number" && Number.isFinite(record.duration_seconds) + ? Math.max(0, Math.round(record.duration_seconds)) + : null; + const format = typeof record.format === "string" ? record.format : null; + const sizeBytes = typeof record.size_bytes === "number" && Number.isFinite(record.size_bytes) + ? Math.max(0, Math.round(record.size_bytes)) + : null; + const modified = typeof record.modified === "string" ? record.modified : null; + const modifiedMs = modified ? new Date(modified).getTime() : null; + const sourceFileName = sourcePath + ? sourcePath.split("/").filter(Boolean).pop() ?? sourcePath + : null; + + return { + messageId, + sourcePath, + sourceFileName, + transcription, + durationSeconds, + format, + sizeBytes, + modified, + modifiedMs: Number.isFinite(modifiedMs ?? NaN) ? modifiedMs : null, + }; + }) + .sort((a, b) => { + const left = a.modifiedMs ?? 0; + const right = b.modifiedMs ?? 0; + if (right !== left) return right - left; + return a.messageId.localeCompare(b.messageId); + }); + + const audioWithTranscriptCount = audioMetadataItems.filter((item) => item.transcription.trim().length > 0).length; + const audioWithKnownDuration = audioMetadataItems.filter((item) => item.durationSeconds != null); + const totalAudioDurationSeconds = audioWithKnownDuration.reduce((sum, item) => sum + (item.durationSeconds ?? 0), 0); + const totalDurationLabel = audioWithKnownDuration.length > 0 ? formatDurationLabel(totalAudioDurationSeconds) : "Unknown"; + const now = new Date(); + const currentMonth = now.toISOString().slice(0, 7); + const todayAudioItems = audioMetadataItems.filter((item) => { + if (!item.modifiedMs) return false; + const itemDate = new Date(item.modifiedMs); + return itemDate.toDateString() === now.toDateString(); + }); + const todayAudioDurationSeconds = todayAudioItems.reduce((sum, item) => sum + (item.durationSeconds ?? 0), 0); + const todayAudioTranscriptsCount = todayAudioItems.filter((item) => item.transcription.trim().length > 0).length; + const monthAudioItems = audioMetadataItems.filter((item) => { + if (!item.modifiedMs) return false; + return toIsoDateFromMs(item.modifiedMs).startsWith(`${currentMonth}-`); + }); + const activityByDay = new Map(); + for (const item of monthAudioItems) { + if (!item.modifiedMs) continue; + const isoDate = toIsoDateFromMs(item.modifiedMs); + activityByDay.set(isoDate, (activityByDay.get(isoDate) ?? 0) + 1); + } + const maxActivityInDay = Math.max(...activityByDay.values(), 0); + const nonDeveloperHabitHeatmapData = Array.from(activityByDay.entries()).map(([date, count]) => ({ + date, + value: maxActivityInDay > 0 ? Math.max(1, Math.round((count / maxActivityInDay) * 100)) : 0, + })); + + if (!developerSettingsEnabled && isWhatsAppConnected) { + return ( +
+
+
+
+
+
+

Dashboard

+

+ Welcome back, {user.firstName || user.emailAddresses[0]?.emailAddress} +

+
+ +
+ +
+ + + {menuOpen && ( +
+
+

{user.fullName || "User"}

+

+ {user.emailAddresses[0]?.emailAddress} +

+
+ +
+ setMenuOpen(false)} + className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-400 transition-all hover:bg-slate-800/50 hover:text-white" + > + + + + + Account settings + + +
+
+ )} +
+
+ ({ + messageId: item.messageId, + transcription: item.transcription, + modified: item.modified, + }))} + /> + + +
+
+
+

Audio Metadata

+

+ Your saved voice notes in a simple, readable view. +

+
+ +
+ +
+ {audioMetadataError ? ( +
+ {audioMetadataError} +
+ ) : audioMetadataLoading ? ( +
Loading audio metadata...
+ ) : audioMetadataCount === 0 ? ( +
No audio metadata found yet.
+ ) : ( +
+
+
+

Voice Notes

+

{audioMetadataCount}

+
+
+

Total Audio Time

+

{totalDurationLabel}

+
+
+

Transcripts Ready

+

{audioWithTranscriptCount}

+
+
+ +
+ {audioMetadataItems.map((item) => ( +
+
+
+

+ Voice note {shortMessageId(item.messageId)} +

+

+ {item.sourceFileName ?? "Unknown source file"} +

+
+ + {formatDurationLabel(item.durationSeconds)} + +
+ +
+ {item.transcription.trim() || "No transcript available yet."} +
+ +
+
+

Recorded

+

{formatDateTimeLabel(item.modified)}

+
+
+

File size

+

{formatBytesLabel(item.sizeBytes)}

+
+
+

Format

+

{item.format ?? "Unknown"}

+
+
+

Status

+

+ {item.transcription.trim() ? "Transcribed" : "Awaiting transcript"} +

+
+
+
+ ))} +
+
+ )} +
+
+
+
+
+ ); + } + const handleStartLinking = async () => { if (!fullPhone.trim()) return; setVerifyLoading(true); @@ -253,89 +627,160 @@ export default function DashboardPage() { } }; - const countdownSeconds = pollUntil - ? Math.max(0, Math.ceil((pollUntil - pollNow) / 1000)) - : 0; + const dashboardHeader = ( +
+
+
+

Dashboard

+

+ Welcome back, {user.firstName || user.emailAddresses[0]?.emailAddress} +

+
+ {isWhatsAppConnected && ( + + )} +
- return ( -
-
- {/* Header */} -
-
-
-

Dashboard

-

- Welcome back, {user.firstName || user.emailAddresses[0]?.emailAddress} -

-
- -
- - {/* User Menu */} -
+ + )} - {menuOpen && ( -
-
-

{user.fullName || "User"}

-

- {user.emailAddresses[0]?.emailAddress} -

-
- -
- setMenuOpen(false)} - className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-slate-400 hover:bg-slate-800/50 hover:text-white transition-all" - > - - - - - Account settings - - -
-
+
+
+ + + {menuOpen && ( +
+
+

{user.fullName || "User"}

+

+ {user.emailAddresses[0]?.emailAddress} +

+
+ +
+ setMenuOpen(false)} + className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-400 transition-all hover:bg-slate-800/50 hover:text-white" + > + + + + + Account settings + + +
+
+ )}
+
+
+ ); + + return ( +
+
+
+ {dashboardHeader} {sessionLoading ? (
@@ -566,291 +1011,308 @@ export default function DashboardPage() {
- ) : ( - <> - {/* Stats Grid */} -
-
-
-
-

Active Sessions

-

1

-
-
- - - + ) : compactMode ? ( +
+
+
+

Active Sessions

+

1

+

WhatsApp connected

-

WhatsApp connected

-
- -
-
-
-

Total Tokens

-

{formatTokens(session?.totalTokens)}

-
-
- - - +
+
+

Total Tokens

+

{formatTokens(session?.totalTokens)}

+

+ {formatTokens(session?.inputTokens)} in / {formatTokens(session?.outputTokens)} out +

-
- {formatTokens(session?.inputTokens)} in - / - {formatTokens(session?.outputTokens)} out -
-
- -
-
-
-

Model

-

{session?.model || "N/A"}

-
-
- - - +
+
+

Model

+

{session?.model || "N/A"}

+

AI provider

-

AI provider

-
- -
-
-
-

Status

-

- {session?.abortedLastRun ? ( - Error - ) : ( - Active - )} +

+
+

Status

+

+ {session?.abortedLastRun ? Error : Active}

-
-
- {session?.abortedLastRun ? ( - - - - ) : ( - - - - )} +

Last active {formatRelativeTime(session?.updatedAt)}

-

Last active {formatRelativeTime(session?.updatedAt)}

-
-
- {/* Main Content */} -
- {/* WhatsApp Session Detail */} -
-
-
- - - -

WhatsApp Session

-
- - {session?.abortedLastRun ? "Error" : "Active"} - +
+
-
- {/* User & Channel */} -
-
- - - -
-
-

{session?.origin?.label || "Unknown"}

-

WhatsApp Direct Message

-
-
-

{formatRelativeTime(session?.updatedAt)}

-

last active

+
+ +
+
+ +
+ + ) : ( + <> +
+
+
+
+

Active Sessions

+

1

+
+
+ + + +
+

WhatsApp connected

- {/* Session Details Grid */} -
-
-

Channel

-
- -

{session?.lastChannel || "N/A"}

+
+
+
+

Total Tokens

+

{formatTokens(session?.totalTokens)}

+
+
+ + +
-
-

Chat Type

-

{session?.chatType || "N/A"}

-
-
-

Compactions

-

{session?.compactionCount ?? 0}

-
-
-

Model

-

{session?.model || "N/A"}

+
+ {formatTokens(session?.inputTokens)} in + / + {formatTokens(session?.outputTokens)} out
- {/* Token Usage Bar */} -
-
-

Token Usage

-

{formatTokens(session?.totalTokens)} total

-
-
-
-
-
-
-
- - Input: {formatTokens(session?.inputTokens)} -
-
- - Output: {formatTokens(session?.outputTokens)} -
+
+
+
+

Model

+

{session?.model || "N/A"}

+
+
+ + +
- of 128k context
+

AI provider

- {/* Origin Details */} -
-

Delivery Context

-
-
-

From

-

{session?.origin?.from || "N/A"}

-
-
-

To

-

{session?.deliveryContext?.to || "N/A"}

-
+
+
-

Channel

-

{session?.deliveryContext?.channel || "N/A"}

+

Status

+

+ {session?.abortedLastRun ? ( + Error + ) : ( + Active + )} +

-
-

Model

-

{session?.model || "N/A"}

+
+ {session?.abortedLastRun ? ( + + + + ) : ( + + + + )}
+

Last active {formatRelativeTime(session?.updatedAt)}

-
- {/* Quick Actions */} -
-
-

Quick Actions

+ {/* Today Overview */} + + + {/* Monthly Habit Heatmap */} + + + {/* Goals & Progress */} + + + {/* Activity Logs entry point */} +
+
+

Activity Logs

+

Jump from your overview into detailed logs exploration

+
+ + View All Logs + + + + +
-
- - +
+ {/* User & Channel */} +
+
+ + + +
+
+

{session?.origin?.label || "Unknown"}

+

WhatsApp Direct Message

+
+
+

{formatRelativeTime(session?.updatedAt)}

+

last active

+
+
- + {/* Session Details Grid */} +
+
+

Channel

+
+ +

{session?.lastChannel || "N/A"}

+
+
+
+

Chat Type

+

{session?.chatType || "N/A"}

+
+
+

Compactions

+

{session?.compactionCount ?? 0}

+
+
+

Model

+

{session?.model || "N/A"}

+
+
- -
- - - -
-
+
-
-
- {/* Recent Activity */} -
-
-

Recent Activity

-
-
-
-
- - - + {/* Recent Activity */} +
+
+

Recent Activity

-
-

WhatsApp session active with {session?.origin?.label || "Unknown"}

-

{formatRelativeTime(session?.updatedAt)} · {formatTokens(session?.totalTokens)} tokens used · {session?.model || "N/A"}

-
- - Active - -
+
+
+
+ + + +
+
+

WhatsApp session active with {session?.origin?.label || "Unknown"}

+

{formatRelativeTime(session?.updatedAt)} · {formatTokens(session?.totalTokens)} tokens used · {session?.model || "N/A"}

+
+ + Active + +
-
+
-
-
- - - -
-
-

WhatsApp number {whatsappPhone} verified

-

Account connected via phone verification

+
+
+ + + +
+
+

WhatsApp number {whatsappPhone} verified

+

Account connected via phone verification

+
+ + Verified + +
- - Verified -
-
-
- + )}
-
+
+ ); } diff --git a/website/app/features/page.tsx b/website/app/features/page.tsx index a07ca02f..68c2389d 100644 --- a/website/app/features/page.tsx +++ b/website/app/features/page.tsx @@ -2,6 +2,7 @@ import React from "react"; import Image from "next/image"; import Link from "next/link"; +import AIPipelineDemo from "@/components/sections/AIPipelineDemo"; // Channel icons as SVG components const ChannelIcons = { @@ -303,6 +304,8 @@ export default function FeaturesPage() { + + {/* Category Comparison Table */}
diff --git a/website/app/globals.css b/website/app/globals.css index 24370d3f..c36a69db 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -78,6 +78,17 @@ body { } } +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .animate-fade-in { animation: fade-in 1s ease-out; } @@ -92,6 +103,30 @@ body { animation-fill-mode: both; } +/* Dashboard section entrance animations */ +.animate-fade-in-up { + animation: fade-in-up 0.45s cubic-bezier(0.16, 1, 0.3, 1) both; +} +.animate-fade-in-up-1 { animation: fade-in-up 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both; } +.animate-fade-in-up-2 { animation: fade-in-up 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.12s both; } +.animate-fade-in-up-3 { animation: fade-in-up 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.19s both; } +.animate-fade-in-up-4 { animation: fade-in-up 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.26s both; } + +/* AI Pipeline — shimmer text sweep */ +@keyframes shimmer-sweep { + 0% { background-position: -200% center; } + 100% { background-position: 200% center; } +} + +.shimmer-text { + background: linear-gradient(90deg, #475569 0%, #94a3b8 45%, #475569 100%); + background-size: 200% auto; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmer-sweep 2.5s linear infinite; +} + /* Scroll-triggered reveal */ .reveal { opacity: 0; @@ -103,3 +138,41 @@ body { opacity: 1; transform: translateY(0); } + +/* Logs Explorer scrollbar — matches app dark theme (slate + brand) */ +.logs-explorer-scroll[data-scrollbar="theme"], +.logs-explorer-scroll { + scrollbar-width: thin; + scrollbar-color: rgb(71 85 105) rgb(15 23 42); +} +/* WebKit: remove default arrows and force dark theme */ +.logs-explorer-scroll[data-scrollbar="theme"]::-webkit-scrollbar, +.logs-explorer-scroll::-webkit-scrollbar { + width: 8px; + height: 8px; +} +.logs-explorer-scroll[data-scrollbar="theme"]::-webkit-scrollbar-button, +.logs-explorer-scroll::-webkit-scrollbar-button { + display: none; + height: 0; + width: 0; +} +.logs-explorer-scroll[data-scrollbar="theme"]::-webkit-scrollbar-track, +.logs-explorer-scroll::-webkit-scrollbar-track { + background: rgb(15 23 42) !important; + border-radius: 9999px; +} +.logs-explorer-scroll[data-scrollbar="theme"]::-webkit-scrollbar-thumb, +.logs-explorer-scroll::-webkit-scrollbar-thumb { + background: rgb(71 85 105) !important; + border-radius: 9999px; +} +.logs-explorer-scroll[data-scrollbar="theme"]::-webkit-scrollbar-thumb:hover, +.logs-explorer-scroll::-webkit-scrollbar-thumb:hover { + background: rgb(100 116 139) !important; +} +.logs-explorer-scroll[data-scrollbar="theme"]::-webkit-scrollbar-corner, +.logs-explorer-scroll::-webkit-scrollbar-corner { + background: rgb(15 23 42); +} + diff --git a/website/app/goals/[id]/page.tsx b/website/app/goals/[id]/page.tsx new file mode 100644 index 00000000..dac150ef --- /dev/null +++ b/website/app/goals/[id]/page.tsx @@ -0,0 +1,842 @@ + "use client"; + +import Link from "next/link"; +import { use, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import TaxonomyPanel from "@/components/taxonomy/TaxonomyPanel"; +import GoalTagList from "@/components/goals/GoalTagList"; +import TagPill from "@/components/tags/TagPill"; +import TagSelector from "@/components/tags/TagSelector"; +import { TAGS, type TagNode } from "@/data/mock/tags"; +import { MOCK_GOALS_WITH_TAGS } from "@/data/mock/goals-with-tags"; +import { + getDetailedGoalsFromLogs, + getGoalRadarFromLogs, + type DetailedGoal, + type GoalCategory, +} from "@/data/test-logs-derived"; +import GoalRadarPanel from "@/components/goals/GoalRadarPanel"; +import { useDemoMode } from "@/hooks/useDemoMode"; +import { useDeveloperSettingsAccess } from "@/hooks/useDeveloperSettingsAccess"; +import { + addTag, + applyTagFilter, + computeTagUsageCounts, + getTagById, +} from "@/utils/tags"; + +type TimelineItem = { + id: string; + date: string; + time: string; + type: "session" | "milestone" | "note"; + text: string; + tagIds: string[]; + value?: { km?: number; hours?: number }; +}; + +type Goal = { + id: string; + name: string; + description: string; + category: GoalCategory; + tagIds: string[]; + startDate: string; + targetDate: string; + progressPercent: number; + streak: number; + why: string; + timeline: TimelineItem[]; +}; + +type GoalsWithTagsState = { + taxonomy: TagNode[]; + goals: Goal[]; +}; + +const categoryColors: Record< + Goal["category"], + { bg: string; text: string; border: string } +> = { + Work: { + bg: "bg-sky-500/10", + text: "text-sky-400", + border: "border-sky-500/20", + }, + Health: { + bg: "bg-emerald-500/10", + text: "text-emerald-400", + border: "border-emerald-500/20", + }, + Relationships: { + bg: "bg-pink-500/10", + text: "text-pink-400", + border: "border-pink-500/20", + }, +}; + +function formatDate(dateString: string) { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function GoalHeader({ + goal, + state, + onGoalTagsChange, + onTagPillClick, +}: { + goal: Goal; + state: GoalsWithTagsState; + onGoalTagsChange: (tagIds: string[]) => void; + onTagPillClick: (tagId: string) => void; +}) { + const colors = categoryColors[goal.category]; + + return ( +
+
+
+ + {goal.category} + + + Streak:{" "} + + {goal.streak} days + + +
+ +
+
+ + Progress + +
+
+
+
+ + {goal.progressPercent}% + +
+
+
+
+ +
+
+

{goal.name}

+

+ {goal.description} +

+
+ +
+
+ + +
+ +
+
+ + Start + + {formatDate(goal.startDate)} +
+ + + + +
+ + Target + + {formatDate(goal.targetDate)} +
+
+
+
+
+ ); +} + +function GoalTimeline({ + goal, + tree, + selectedTagIds, + onTagClick, + onItemTagsChange, +}: { + goal: Goal; + tree: GoalsWithTagsState["taxonomy"]; + selectedTagIds: string[]; + onTagClick: (tagId: string) => void; + onItemTagsChange: (itemId: string, tagIds: string[]) => void; +}) { + const sortedEvents = [...goal.timeline].sort((a, b) => { + const aKey = `${a.date}T${a.time}`; + const bKey = `${b.date}T${b.time}`; + return aKey < bKey ? 1 : aKey > bKey ? -1 : 0; + }); + + return ( +
+
+
+

Timeline

+

+ Recent sessions, milestones, and notes for this goal +

+
+ + {sortedEvents.length}{" "} + {sortedEvents.length === 1 ? "event" : "events"} + +
+ +
+ {sortedEvents.length === 0 ? ( +

+ No events logged yet for this goal. +

+ ) : ( +
+
    + {sortedEvents.map((event, index) => { + const isMilestone = event.type === "milestone"; + const isNote = event.type === "note"; + + const dotColor = isMilestone + ? "bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.20)]" + : isNote + ? "bg-sky-400 shadow-[0_0_0_3px_rgba(56,189,248,0.20)]" + : "bg-emerald-400 shadow-[0_0_0_3px_rgba(74,222,128,0.20)]"; + + const isLast = index === sortedEvents.length - 1; + + return ( +
  1. +
    + + {!isLast && ( + + )} +
    + +
    +
    +
    + + {event.type === "milestone" + ? "Milestone" + : event.type === "note" + ? "Note" + : "Session"} + + + {formatDate(event.date)} at {event.time} + +
    + +
    + +

    + {event.text} +

    + + {event.value && ( +

    + {"km" in event.value && `${event.value.km} km`} + {"hours" in event.value && `${event.value.hours}h`} +

    + )} +
    + onItemTagsChange(event.id, tagIds)} + buttonLabel="Edit item tags" + /> +
    +
    +
  2. + ); + })} +
+
+ )} +
+
+ ); +} + +function TimelineWithTags({ + goal, + state, + selectedTagIds, + onTagClick, + onItemTagsChange, +}: { + goal: Goal; + state: GoalsWithTagsState; + selectedTagIds: string[]; + onTagClick: (tagId: string) => void; + onItemTagsChange: (itemId: string, tagIds: string[]) => void; +}) { + const filteredEvents = useMemo( + () => applyTagFilter(goal.timeline, selectedTagIds), + [goal.timeline, selectedTagIds] + ); + + return ( +
+ + {selectedTagIds.length > 0 && ( +
+ Filtered by: + {selectedTagIds.map((tagId) => { + const tag = getTagById(tagId, state.taxonomy); + if (!tag) return null; + return ( + onTagClick(tag.id)} + /> + ); + })} + +
+ )} +
+ ); +} + +function TimelineItemTags({ + item, + tree, + selectedTagIds, + onTagClick, +}: { + item: TimelineItem; + tree: GoalsWithTagsState["taxonomy"]; + selectedTagIds: string[]; + onTagClick: (tagId: string) => void; +}) { + if (item.tagIds.length === 0) return null; + const visible = item.tagIds.slice(0, 3); + const overflow = item.tagIds.length - visible.length; + return ( +
+ {visible.map((tagId) => { + const tag = getTagById(tagId, tree); + if (!tag) return null; + return ( + onTagClick(tag.id)} + /> + ); + })} + {overflow > 0 && ( + + +{overflow} + + )} +
+ ); +} + +function formatCompactDate(dateString: string) { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +function ProgressLineChart({ series }: { series: { date: string; value: number }[] }) { + const sortedSeries = [...series].sort((a, b) => a.date.localeCompare(b.date)); + const chartWidth = 320; + const chartHeight = 120; + const padding = 12; + + if (sortedSeries.length === 0) { + return ( +
+

No progress history yet.

+
+ ); + } + + const maxValue = Math.max(100, ...sortedSeries.map((point) => point.value)); + const xStep = + sortedSeries.length > 1 ? (chartWidth - padding * 2) / (sortedSeries.length - 1) : 0; + + const points = sortedSeries + .map((point, index) => { + const x = padding + index * xStep; + const y = + chartHeight - padding - (point.value / maxValue) * (chartHeight - padding * 2); + return `${x},${y}`; + }) + .join(" "); + + const latestValue = sortedSeries[sortedSeries.length - 1]?.value ?? 0; + + return ( +
+
+

Progress over time

+ + {latestValue}% + +
+ + + + + {sortedSeries.map((point, index) => { + const x = padding + index * xStep; + const y = + chartHeight - padding - (point.value / maxValue) * (chartHeight - padding * 2); + return ( + + ); + })} + + +
+ {formatCompactDate(sortedSeries[0].date)} + {formatCompactDate(sortedSeries[sortedSeries.length - 1].date)} +
+
+ ); +} + +function GoalInsights({ goal }: { goal: Goal }) { + const sorted = [...goal.timeline].sort((a, b) => + `${a.date}T${a.time}` > `${b.date}T${b.time}` ? 1 : -1 + ); + const milestones = sorted.filter((event) => event.type === "milestone"); + const sessions = sorted.filter((event) => event.type === "session").length; + const avgSessionsPerWeek = sessions > 0 ? (sessions / 2).toFixed(1) : "0.0"; + + const chartSeries = (() => { + if (sorted.length === 0) { + return [{ date: goal.startDate, value: goal.progressPercent }]; + } + const uniqueDates = Array.from(new Set(sorted.map((event) => event.date))); + return uniqueDates.map((date, index) => ({ + date, + value: Math.round(((index + 1) / uniqueDates.length) * goal.progressPercent), + })); + })(); + + type Contribution = "major" | "minor" | "note"; + const mappedEvents = [...sorted] + .reverse() + .slice(0, 6) + .map((event, index) => ({ + id: `${goal.id}-contrib-${index}`, + date: event.date, + text: event.text, + contribution: + event.type === "session" + ? ("major" as Contribution) + : event.type === "milestone" + ? ("minor" as Contribution) + : ("note" as Contribution), + })); + + const contributionStyles: Record = { + major: "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30", + minor: "bg-sky-500/15 text-sky-300 border border-sky-500/30", + note: "bg-slate-700/40 text-slate-300 border border-slate-600/60", + }; + + return ( +
+
+

Goal Insights

+

+ Momentum, milestones, activity patterns, and motivation. +

+
+ +
+ + +
+

Milestones

+ {milestones.length === 0 ? ( +

No milestones yet.

+ ) : ( +
    + {milestones.map((milestone) => ( +
  • +
    +

    {milestone.text}

    +

    {formatDate(milestone.date)}

    +
    + + Completed + +
  • + ))} +
+ )} +
+ +
+

Activity → Goal Mapping

+
    + {mappedEvents.map((event) => ( +
  • +
    +

    {event.text}

    +

    {formatDate(event.date)}

    +
    + + {event.contribution} + +
  • + ))} +
+
+ +
+

Effort & Consistency

+
+
+

🔥 Streak

+

{goal.streak} days

+
+
+

Avg sessions/week

+

{avgSessionsPerWeek}

+
+
+

Total sessions

+

{sessions}

+
+
+
+ +
+

Why this goal matters

+

+ {goal.why || "Keep moving toward the person you want to become."} +

+

+ This is your long-term motivation +

+
+
+
+ ); +} + +interface GoalDetailPageProps { + params: Promise<{ id: string }>; +} + +function mapDetailedGoalToStateGoal(goal: DetailedGoal): Goal { + return { + id: goal.id, + name: goal.name, + description: goal.description, + category: goal.category, + tagIds: Array.from(new Set(goal.tags)), + startDate: goal.startDate, + targetDate: goal.targetDate, + progressPercent: goal.progressPercent, + streak: goal.streak, + why: goal.why, + timeline: goal.events.map((event, index) => ({ + id: `${goal.id}-${event.date}-${event.time}-${index}`, + date: event.date, + time: event.time, + type: event.type, + text: event.text, + tagIds: Array.from(new Set(event.tags)), + value: event.value ?? undefined, + })), + }; +} + +export default function GoalDetailPage({ params }: GoalDetailPageProps) { + const { isCheckingAccess, isBlocked } = useDeveloperSettingsAccess(); + const { isDemoMode } = useDemoMode(); + const { id } = use(params); + const searchParams = useSearchParams(); + const sourceGoals = useMemo( + () => + isDemoMode + ? MOCK_GOALS_WITH_TAGS + : getDetailedGoalsFromLogs().map(mapDetailedGoalToStateGoal), + [isDemoMode] + ); + const [taxonomy, setTaxonomy] = useState(TAGS); + const [goalTagOverrides, setGoalTagOverrides] = useState>({}); + const [timelineTagOverrides, setTimelineTagOverrides] = useState< + Record> + >({}); + const [selectedTagIds, setSelectedTagIds] = useState(() => { + const categoryTag = searchParams.get("category"); + return categoryTag ? [categoryTag] : []; + }); + + const goals = useMemo( + () => + sourceGoals.map((goal) => { + const timelineOverrides = timelineTagOverrides[goal.id] ?? {}; + return { + ...goal, + tagIds: goalTagOverrides[goal.id] ?? goal.tagIds, + timeline: goal.timeline.map((item) => ({ + ...item, + tagIds: timelineOverrides[item.id] ?? item.tagIds, + })), + }; + }), + [sourceGoals, goalTagOverrides, timelineTagOverrides] + ); + const state = useMemo(() => ({ taxonomy, goals }), [taxonomy, goals]); + + const goal = state.goals.find((item) => item.id === id); + const usageCounts = useMemo(() => computeTagUsageCounts(state.goals), [state.goals]); + + if (isCheckingAccess || isBlocked) { + return ( +
+
+ Loading... +
+
+ ); + } + + if (!goal) { + return ( +
+
+

Goal not found.

+ + Back to goals + +
+
+ ); + } + + const onTimelineTagClick = (tagId: string) => { + setSelectedTagIds((current) => + current.includes(tagId) ? current.filter((id) => id !== tagId) : [...current, tagId] + ); + }; + const radarData = getGoalRadarFromLogs(goal.id); + + return ( +
+
+
+ + + + + Back to goals + +
+ + setGoalTagOverrides((current) => ({ ...current, [goal.id]: tagIds }))} + /> + +
+
+
+ setTaxonomy((current) => addTag(current, input))} + /> +
+
+ + Taxonomy & filters + +
+ setTaxonomy((current) => addTag(current, input))} + /> +
+
+
+ +
+
+
+
+

Life Balance Radar

+

+ Static preview of this goal across key dimensions. +

+
+ + Derived from test logs + +
+
+ +
+
+ + Direct: Work, Health, Relationships + + + Inferred: Mind, Body, Social (from text + tags) + +
+
+ + + setTimelineTagOverrides((current) => ({ + ...current, + [goal.id]: { + ...(current[goal.id] ?? {}), + [itemId]: tagIds, + }, + })) + } + /> +
+ + +
+
+
+ ); +} diff --git a/website/app/goals/page.tsx b/website/app/goals/page.tsx new file mode 100644 index 00000000..d9d67b03 --- /dev/null +++ b/website/app/goals/page.tsx @@ -0,0 +1,128 @@ + "use client"; + +import { useMemo, useState } from "react"; +import GoalCard from "@/components/goals/GoalCard"; +import { TAGS } from "@/data/mock/tags"; +import { MOCK_GOALS_WITH_TAGS } from "@/data/mock/goals-with-tags"; +import { getDetailedGoalsFromLogs } from "@/data/test-logs-derived"; +import { applyTagFilter } from "@/utils/tags"; +import { useDemoMode } from "@/hooks/useDemoMode"; +import { useDeveloperSettingsAccess } from "@/hooks/useDeveloperSettingsAccess"; + +export default function GoalsPage() { + const { isCheckingAccess, isBlocked } = useDeveloperSettingsAccess(); + const { isDemoMode } = useDemoMode(); + const [selectedTags, setSelectedTags] = useState([]); + const taxonomy = TAGS; + const goals = useMemo( + () => { + if (isDemoMode) { + return MOCK_GOALS_WITH_TAGS.map((goal) => ({ + id: goal.id, + name: goal.name, + description: goal.description, + category: goal.category, + startDate: goal.startDate, + targetDate: goal.targetDate, + progressPercent: goal.progressPercent, + streak: goal.streak, + tagIds: Array.from(new Set(goal.tagIds)), + eventCount: goal.timeline.length, + })); + } + return getDetailedGoalsFromLogs().map((goal) => ({ + id: goal.id, + name: goal.name, + description: goal.description, + category: goal.category, + startDate: goal.startDate, + targetDate: goal.targetDate, + progressPercent: goal.progressPercent, + streak: goal.streak, + tagIds: Array.from(new Set(goal.tags)), + eventCount: goal.events.length, + })); + }, + [isDemoMode] + ); + + const sortedGoals = useMemo(() => { + const filtered = applyTagFilter(goals, selectedTags); + return [...filtered].sort((a, b) => a.name.localeCompare(b.name)); + }, [goals, selectedTags]); + + if (isCheckingAccess || isBlocked) { + return ( +
+
+ Loading... +
+
+ ); + } + + return ( +
+
+
+

Goals

+

+ High-level outcomes you're driving, with taxonomy-aware tags. +

+ {selectedTags.length > 0 && ( +
+ + Filtering by {selectedTags.length} tag + {selectedTags.length > 1 ? "s" : ""} + + +
+ )} +
+ + {sortedGoals.length === 0 ? ( +
+

+ {selectedTags.length > 0 ? "No goals match selected tags." : "No goals yet."} +

+

+ {selectedTags.length > 0 + ? "Clear filters or add logs with matching tags." + : "Start logging goal-linked activity to populate this page."} +

+ {selectedTags.length > 0 ? ( + + ) : null} +
+ ) : ( +
+ {sortedGoals.map((goal) => ( + + setSelectedTags((curr) => + curr.includes(tagId) ? curr.filter((id) => id !== tagId) : [...curr, tagId] + ) + } + /> + ))} +
+ )} +
+
+ ); +} diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 795a76c5..e5d44b87 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -1,12 +1,13 @@ import "./globals.css"; import type { Metadata } from "next"; import { ClerkProvider } from "@clerk/nextjs"; -import ConditionalNavbar from "./components/ConditionalNavbar"; -import PageWrapper from "./components/PageWrapper"; -import { ThemeProvider } from "./contexts/ThemeContext"; -import { WhatsAppWidgetProvider } from "./contexts/WhatsAppWidgetContext"; -import WhatsAppWidget from "./components/WhatsAppWidget"; -import { WebVitals } from "./components/WebVitals"; +import ConditionalNavbar from "@/components/ConditionalNavbar"; +import PageWrapper from "@/components/PageWrapper"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import { WhatsAppWidgetProvider } from "@/contexts/WhatsAppWidgetContext"; +import WhatsAppWidget from "@/components/WhatsAppWidget"; +import { WebVitals } from "@/components/WebVitals"; +import SupportButton from "@/components/SupportButton"; export const metadata: Metadata = { title: "LogLife — Effortless Tracking, in Chat", @@ -92,6 +93,7 @@ export default function RootLayout({ {children} + diff --git a/website/app/login/page.tsx b/website/app/login/page.tsx index 376c5150..29f7f055 100644 --- a/website/app/login/page.tsx +++ b/website/app/login/page.tsx @@ -12,6 +12,7 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [oauthLoading, setOauthLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -40,6 +41,8 @@ export default function LoginPage() { const handleGoogleSignIn = async () => { if (!isLoaded) return; + setOauthLoading(true); + setError(""); try { await signIn.authenticateWithRedirect({ strategy: "oauth_google", @@ -49,6 +52,7 @@ export default function LoginPage() { } catch (err: unknown) { const clerkError = err as { errors?: { message: string }[] }; setError(clerkError.errors?.[0]?.message || "OAuth sign in failed"); + setOauthLoading(false); } }; @@ -84,15 +88,25 @@ export default function LoginPage() {
diff --git a/website/app/logs/page.tsx b/website/app/logs/page.tsx new file mode 100644 index 00000000..98084f39 --- /dev/null +++ b/website/app/logs/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Suspense } from "react"; +import LogsPage from "@/components/logs/LogsPage"; +import { useUser } from "@clerk/nextjs"; +import NonDeveloperVoiceLogsPage from "@/components/logs/NonDeveloperVoiceLogsPage"; + +export default function LogsRoutePage() { + const { user, isLoaded } = useUser(); + const developerSettingsEnabled = Boolean( + (user?.unsafeMetadata as Record | undefined)?.developerSettingsEnabled + ); + const whatsappPhone = (user?.unsafeMetadata as Record | undefined)?.whatsappPhone || ""; + + if (!isLoaded) { + return
Loading...
; + } + + if (!user) { + return
Loading...
; + } + + if (!developerSettingsEnabled) { + return ; + } + + return ( + Loading...
}> + + + ); +} diff --git a/website/app/page.tsx b/website/app/page.tsx index c9dca510..16d92f1d 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import LogLifeHero from "./hero/hero"; -import Footer from "./components/Footer"; +import LogLifeHero from "@/components/hero/LogLifeHero"; +import Footer from "@/components/Footer"; export default function HomePage() { return ( diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx index 3f1a3dfa..bb3faa48 100644 --- a/website/app/pricing/page.tsx +++ b/website/app/pricing/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef, useCallback } from "react"; import Link from "next/link"; +import AnimatedComparison from "@/components/pricing/AnimatedComparison"; function CheckIcon({ className = "w-5 h-5" }: { className?: string }) { return ( @@ -10,329 +10,6 @@ function CheckIcon({ className = "w-5 h-5" }: { className?: string }) { ); } -const oldSteps = [ - "Rent a Private Server", - "Create 5 API keys", - "Clone and install OpenClaw", - "Configure OpenClaw", - "Clone LogLife and install Plug-in", - "Launch web dashboard", -]; - -const newSteps = [ - "Sign up", - "Start Messaging AI", - "See Habits in Dashboard", -]; - -interface ComparisonState { - oldActive: number[]; - oldDone: number[]; - oldTexts: string[]; - newActive: number[]; - newTexts: string[]; - oldTime: string; - newTime: string; - showSummary: boolean; -} - -const INITIAL_STATE: ComparisonState = { - oldActive: [], - oldDone: [], - oldTexts: oldSteps.map(() => ""), - newActive: [], - newTexts: newSteps.map(() => ""), - oldTime: "0h 00m", - newTime: "0m 00s", - showSummary: false, -}; - -type ComparisonAction = - | { type: "RESET" } - | { type: "OLD_ACTIVATE"; idx: number } - | { type: "OLD_TYPE"; idx: number; text: string } - | { type: "OLD_DONE"; idx: number } - | { type: "OLD_TIME"; value: string } - | { type: "NEW_ACTIVATE"; idx: number } - | { type: "NEW_TIME"; value: string } - | { type: "SHOW_SUMMARY" }; - -function comparisonReducer(state: ComparisonState, action: ComparisonAction): ComparisonState { - switch (action.type) { - case "RESET": - return { - oldActive: [], - oldDone: [], - oldTexts: oldSteps.map(() => ""), - newActive: [], - newTexts: newSteps.map(() => ""), - oldTime: "0h 00m", - newTime: "0m 00s", - showSummary: false, - }; - case "OLD_ACTIVATE": - return { ...state, oldActive: [...state.oldActive, action.idx] }; - case "OLD_TYPE": { - const texts = [...state.oldTexts]; - texts[action.idx] = action.text; - return { ...state, oldTexts: texts }; - } - case "OLD_DONE": - return { ...state, oldDone: [...state.oldDone, action.idx] }; - case "OLD_TIME": - return { ...state, oldTime: action.value }; - case "NEW_ACTIVATE": { - const texts = [...state.newTexts]; - texts[action.idx] = newSteps[action.idx]; - return { ...state, newActive: [...state.newActive, action.idx], newTexts: texts }; - } - case "NEW_TIME": - return { ...state, newTime: action.value }; - case "SHOW_SUMMARY": - return { ...state, showSummary: true }; - default: - return state; - } -} - -function formatOldTime(s: number) { - const h = Math.floor(s / 3600); - const m = Math.floor((s % 3600) / 60); - return `${h}h ${m < 10 ? "0" : ""}${m}m`; -} - -function formatNewTime(s: number) { - const m = Math.floor(s / 60); - const sec = s % 60; - return `${m}m ${sec < 10 ? "0" : ""}${sec}s`; -} - -function AnimatedComparison() { - const gridRef = useRef(null); - const [state, dispatch] = React.useReducer(comparisonReducer, INITIAL_STATE); - const runningRef = useRef(false); - const timersRef = useRef[]>([]); - - const clearTimers = useCallback(() => { - timersRef.current.forEach((id) => { clearInterval(id); clearTimeout(id); }); - timersRef.current = []; - }, []); - - const addTimer = useCallback((id: ReturnType) => { timersRef.current.push(id); }, []); - - const reset = useCallback(() => { - clearTimers(); - runningRef.current = false; - dispatch({ type: "RESET" }); - }, [clearTimers]); - - const run = useCallback(() => { - if (runningRef.current) return; - runningRef.current = true; - dispatch({ type: "RESET" }); - - const start = setTimeout(() => { - // --- Phase 1: Self-Hosted (left side types out fully, ~8s) --- - const CHAR_SPEED = 30; - const STEP_PAUSE = 800; - const OLD_TARGET = 16200; - const oldClockDuration = 8000; - - const clockStart = Date.now(); - const oldClock = setInterval(() => { - const elapsed = Date.now() - clockStart; - const progress = Math.min(elapsed / oldClockDuration, 1); - dispatch({ type: "OLD_TIME", value: formatOldTime(Math.floor(progress * OLD_TARGET)) }); - if (progress >= 1) clearInterval(oldClock); - }, 30); - addTimer(oldClock); - - function typeOldStep(idx: number) { - if (idx >= oldSteps.length) { - // Phase 1 done → pause → start Phase 2 - addTimer(setTimeout(startHosted, 500)); - return; - } - dispatch({ type: "OLD_ACTIVATE", idx }); - const text = oldSteps[idx]; - let ci = 0; - const iv = setInterval(() => { - if (ci < text.length) { - ci++; - dispatch({ type: "OLD_TYPE", idx, text: text.slice(0, ci) }); - } else { - clearInterval(iv); - dispatch({ type: "OLD_DONE", idx }); - addTimer(setTimeout(() => typeOldStep(idx + 1), STEP_PAUSE)); - } - }, CHAR_SPEED); - addTimer(iv); - } - typeOldStep(0); - - // --- Phase 2: Hosted (right side, starts after left finishes) --- - function startHosted() { - const NEW_STEP_DELAY = 800; - const NEW_TARGET = 90; - const newTotalMs = 2500; - - const newClockStart = Date.now(); - const newClock = setInterval(() => { - const elapsed = Date.now() - newClockStart; - const progress = Math.min(elapsed / newTotalMs, 1); - dispatch({ type: "NEW_TIME", value: formatNewTime(Math.floor(progress * NEW_TARGET)) }); - if (progress >= 1) clearInterval(newClock); - }, 30); - addTimer(newClock); - - function showNewStep(idx: number) { - if (idx >= newSteps.length) { - // Phase 2 done → pause → show punchline - addTimer(setTimeout(() => dispatch({ type: "SHOW_SUMMARY" }), 500)); - addTimer( - setTimeout(() => { - runningRef.current = false; - run(); - }, 8000) - ); - return; - } - dispatch({ type: "NEW_ACTIVATE", idx }); - addTimer(setTimeout(() => showNewStep(idx + 1), NEW_STEP_DELAY)); - } - showNewStep(0); - } - }, 50); - addTimer(start); - }, [addTimer]); - - useEffect(() => { - const grid = gridRef.current; - if (!grid) return; - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !runningRef.current) run(); - else if (!entry.isIntersecting && runningRef.current) reset(); - }); - }, - { threshold: 0.3 } - ); - - observer.observe(grid); - return () => { observer.unobserve(grid); clearTimers(); }; - }, [run, reset, clearTimers]); - - return ( -
- - -
- The Difference -

- Same product. Two paths. -

-

- See what changes when we handle the infrastructure. -

-
- -
- - {/* Self-Hosted (slow) */} -
-
- Self-Hosted -
- -
-
- - - - Terminal -
-
- {oldSteps.map((_, i) => ( -
- - {state.oldTexts[i]} -
- ))} -
-
- -
- Time elapsed: - {state.oldTime} -
-
- - {/* Hosted (fast) */} -
-
- Hosted by LogLife -
- -
-
- - - - LogLife -
-
- {newSteps.map((_, i) => ( -
- - {state.newTexts[i]} -
- ))} -
-
- -
- Time elapsed: - {state.newTime} -
-
- -
- -
- - 180x - - - faster setup. Always stable & up-to-date. - - - We handle infrastructure, APIs, and updates. You focus on logging. - -
-
- ); -} - export default function PricingPage() { diff --git a/website/app/signup/page.tsx b/website/app/signup/page.tsx index 59bf7f90..c08e945d 100644 --- a/website/app/signup/page.tsx +++ b/website/app/signup/page.tsx @@ -14,6 +14,7 @@ export default function SignupPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [oauthLoading, setOauthLoading] = useState(false); const [pendingVerification, setPendingVerification] = useState(false); const [code, setCode] = useState(""); @@ -66,6 +67,8 @@ export default function SignupPage() { const handleGoogleSignUp = async () => { if (!isLoaded) return; + setOauthLoading(true); + setError(""); try { await signUp.authenticateWithRedirect({ strategy: "oauth_google", @@ -75,6 +78,7 @@ export default function SignupPage() { } catch (err: unknown) { const clerkError = err as { errors?: { message: string }[] }; setError(clerkError.errors?.[0]?.message || "OAuth sign up failed"); + setOauthLoading(false); } }; @@ -154,15 +158,25 @@ export default function SignupPage() {
diff --git a/website/app/stats/page.tsx b/website/app/stats/page.tsx new file mode 100644 index 00000000..6e545ae4 --- /dev/null +++ b/website/app/stats/page.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { useMemo, useState } from "react"; +import KPICard from "@/components/stats/KPICard"; +import TimeSeriesChart, { type SeriesConfig, type SeriesKey } from "@/components/stats/TimeSeriesChart"; +import AreaChart from "@/components/stats/AreaChart"; +import DistributionChart, { type HistogramBucket } from "@/components/stats/DistributionChart"; +import ChartControls from "@/components/stats/ChartControls"; +import { + getDailyStatsFromLogs, + getSessionLengthsFromLogs, + getTopEventsFromLogs, + RANGE_OPTIONS, + type DateRange, + type TopEvent, +} from "@/data/test-logs-derived"; +import { mockDailyStats, mockSessionLengths, mockTopEvents } from "@/data/mock-stats"; +import { useDemoMode } from "@/hooks/useDemoMode"; +import { useDeveloperSettingsAccess } from "@/hooks/useDeveloperSettingsAccess"; + +const SERIES_CONFIG: SeriesConfig[] = [ + { key: "total", label: "Total", color: "#22d3ee" }, + { key: "work", label: "Work", color: "#60a5fa" }, + { key: "health", label: "Health", color: "#34d399" }, + { key: "relationships", label: "Relationships", color: "#f59e0b" }, +]; + +function average(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + +function changePercent(current: number, previous: number): number { + if (previous === 0) return 0; + return ((current - previous) / previous) * 100; +} + +function buildHistogram(values: number[]): HistogramBucket[] { + const step = 10; + const max = 100; + const buckets: HistogramBucket[] = []; + + for (let start = 0; start < max; start += step) { + const end = start + step; + const count = values.filter((value) => value >= start && value < end).length; + buckets.push({ range: `${start}-${end}`, count }); + } + + return buckets; +} + +function formatImportanceColor(importance: TopEvent["importance"]): string { + if (importance === "Critical") return "bg-rose-500/15 text-rose-300"; + if (importance === "High") return "bg-orange-500/15 text-orange-300"; + if (importance === "Medium") return "bg-amber-500/15 text-amber-300"; + return "bg-slate-700/70 text-slate-300"; +} + +export default function StatsPage() { + const { isCheckingAccess, isBlocked } = useDeveloperSettingsAccess(); + const { isDemoMode } = useDemoMode(); + const [range, setRange] = useState(30); + const [smoothing, setSmoothing] = useState(true); + const [hiddenSeries, setHiddenSeries] = useState>>({}); + const [selectedEvent, setSelectedEvent] = useState(null); + + const dailyStats = useMemo( + () => (isDemoMode ? mockDailyStats : getDailyStatsFromLogs()), + [isDemoMode] + ); + const sessionLengths = useMemo( + () => (isDemoMode ? mockSessionLengths : getSessionLengthsFromLogs()), + [isDemoMode] + ); + const topEvents = useMemo( + () => (isDemoMode ? mockTopEvents : getTopEventsFromLogs(20)), + [isDemoMode] + ); + const hasStatsData = dailyStats.length > 0; + + const sourceDailyStats = dailyStats; + const sourceSessionLengths = sessionLengths; + const sourceTopEvents = topEvents; + + const filteredData = useMemo(() => sourceDailyStats.slice(-range), [sourceDailyStats, range]); + const previousPeriodData = useMemo(() => sourceDailyStats.slice(-range * 2, -range), [sourceDailyStats, range]); + + const visibleSessionLengths = useMemo(() => { + const approximateSamplesPerDay = 6; + return sourceSessionLengths.slice(-range * approximateSamplesPerDay); + }, [sourceSessionLengths, range]); + + const previousSessionLengths = useMemo(() => { + const approximateSamplesPerDay = 6; + return sourceSessionLengths.slice(-range * approximateSamplesPerDay * 2, -range * approximateSamplesPerDay); + }, [sourceSessionLengths, range]); + + const histogramData = useMemo(() => buildHistogram(visibleSessionLengths), [visibleSessionLengths]); + + const kpis = useMemo(() => { + const currentTotals = filteredData.map((d) => d.total); + const previousTotals = previousPeriodData.map((d) => d.total); + + const avgDaily = average(currentTotals); + const previousAvgDaily = average(previousTotals); + + const totalActivities = currentTotals.reduce((sum, value) => sum + value, 0); + const previousTotalActivities = previousTotals.reduce((sum, value) => sum + value, 0); + + const activeDays = filteredData.filter((d) => d.total > 0).length; + const previousActiveDays = previousPeriodData.filter((d) => d.total > 0).length; + + const medianSession = median(visibleSessionLengths); + const previousMedianSession = median(previousSessionLengths); + + return { + avgDaily, + avgDailyChange: changePercent(avgDaily, previousAvgDaily), + totalActivities, + totalActivitiesChange: changePercent(totalActivities, previousTotalActivities), + activeDays, + activeDaysChange: changePercent(activeDays, previousActiveDays), + medianSession, + medianSessionChange: changePercent(medianSession, previousMedianSession), + }; + }, [filteredData, previousPeriodData, previousSessionLengths, visibleSessionLengths]); + + const sparklineTotals = useMemo(() => filteredData.slice(-24).map((d) => d.total), [filteredData]); + + const events = useMemo( + () => { + if (filteredData.length === 0) return []; + const startDate = filteredData[0].date; + return sourceTopEvents.filter((event) => event.date >= startDate); + }, + [sourceTopEvents, filteredData] + ); + + const toggleSeries = (key: SeriesKey) => { + setHiddenSeries((current) => ({ + ...current, + [key]: !current[key], + })); + }; + + const handleExportCsv = () => { + const visibleSeries = SERIES_CONFIG.filter((series) => !hiddenSeries[series.key]); + const header = ["date", ...visibleSeries.map((s) => s.key)].join(","); + const lines = filteredData.map((row) => { + const values = visibleSeries.map((s) => row[s.key]); + return [row.date, ...values].join(","); + }); + const csvContent = [header, ...lines].join("\n"); + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `loglife-stats-last-${range}-days.csv`; + link.click(); + URL.revokeObjectURL(url); + }; + + if (isCheckingAccess || isBlocked) { + return ( +
+
+ Loading... +
+
+ ); + } + + return ( +
+
+
+

Detailed Statistics

+

+ In-depth time-series analytics for activity trends, category mix, and standout events. +

+
+ +
+ +
+ + {!hasStatsData ? ( +
+

No stats data yet.

+

+ Start logging activity to populate detailed statistics. +

+
+ ) : ( + <> +
+ + + d.work + d.health + d.relationships)} + /> + (d.total > 0 ? 1 : 0))} + /> +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+

Top Events & Anomalies

+

Click inspect to open the raw event payload

+
+ {events.length} rows +
+ +
+ + + + + + + + + + + + + {events.map((event) => ( + + + + + + + + + ))} + +
DateTimeEventCategoryImportanceAction
{event.date}{event.time}{event.text}{event.category} + + {event.importance} + + + +
+
+
+ + )} + + {selectedEvent ? ( +
+
+
+

Event JSON

+ +
+
+
+                  {JSON.stringify(selectedEvent, null, 2)}
+                
+
+
+
+ ) : null} +
+
+ ); +} diff --git a/website/app/components/ConditionalNavbar.tsx b/website/components/ConditionalNavbar.tsx similarity index 100% rename from website/app/components/ConditionalNavbar.tsx rename to website/components/ConditionalNavbar.tsx diff --git a/website/app/components/Footer.tsx b/website/components/Footer.tsx similarity index 100% rename from website/app/components/Footer.tsx rename to website/components/Footer.tsx diff --git a/website/app/components/PageWrapper.tsx b/website/components/PageWrapper.tsx similarity index 100% rename from website/app/components/PageWrapper.tsx rename to website/components/PageWrapper.tsx diff --git a/website/app/components/Sidebar.tsx b/website/components/Sidebar.tsx similarity index 100% rename from website/app/components/Sidebar.tsx rename to website/components/Sidebar.tsx diff --git a/website/components/SupportButton.tsx b/website/components/SupportButton.tsx new file mode 100644 index 00000000..cd13de9b --- /dev/null +++ b/website/components/SupportButton.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import SupportModal from "./SupportModal"; + +export default function SupportButton() { + const [isOpen, setIsOpen] = useState(false); + const [isVisible, setIsVisible] = useState(false); + const triggerRef = useRef(null); + const wasOpenRef = useRef(false); + + useEffect(() => { + const frame = requestAnimationFrame(() => setIsVisible(true)); + return () => cancelAnimationFrame(frame); + }, []); + + useEffect(() => { + if (!isOpen && wasOpenRef.current) { + triggerRef.current?.focus(); + } + wasOpenRef.current = isOpen; + }, [isOpen]); + + return ( + <> + + + setIsOpen(false)} /> + + ); +} diff --git a/website/components/SupportModal.tsx b/website/components/SupportModal.tsx new file mode 100644 index 00000000..26270fca --- /dev/null +++ b/website/components/SupportModal.tsx @@ -0,0 +1,466 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; + +type SupportModalProps = { + isOpen: boolean; + onClose: () => void; +}; + +type SupportFormState = { + type: string; + subject: string; + email: string; + message: string; + attachment: File | null; +}; + +const INITIAL_FORM_STATE: SupportFormState = { + type: "Feedback", + subject: "", + email: "", + message: "", + attachment: null, +}; + +const MAX_ATTACHMENT_SIZE_MB = 5; +const MAX_ATTACHMENT_BYTES = MAX_ATTACHMENT_SIZE_MB * 1024 * 1024; + +const inputBase = + "w-full rounded-xl border border-white/10 bg-white/5 px-3.5 py-2.5 text-sm text-white placeholder:text-zinc-500 transition-all duration-200 focus:border-emerald-500/50 focus:bg-white/[0.07] focus:outline-none focus:ring-2 focus:ring-emerald-500/50"; + +export default function SupportModal({ isOpen, onClose }: SupportModalProps) { + const [formState, setFormState] = useState(INITIAL_FORM_STATE); + const [isSending, setIsSending] = useState(false); + const [isSent, setIsSent] = useState(false); + const [error, setError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const modalRef = useRef(null); + const fileInputRef = useRef(null); + /** Ref updated synchronously in onChange so we never open the picker twice after selection */ + const hasAttachmentRef = useRef(false); + + const resetState = () => { + setFormState(INITIAL_FORM_STATE); + hasAttachmentRef.current = false; + if (fileInputRef.current) fileInputRef.current.value = ""; + setIsSending(false); + setIsSent(false); + setError(null); + }; + + const handleClose = useCallback(() => { + resetState(); + onClose(); + }, [onClose]); + + useEffect(() => { + if (!isOpen) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const focusableSelector = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + const focusFirstElement = () => { + const focusableElements = modalRef.current?.querySelectorAll(focusableSelector); + focusableElements?.[0]?.focus(); + }; + + const keydownHandler = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + handleClose(); + return; + } + + if (event.key !== "Tab" || !modalRef.current) return; + + const focusableElements = Array.from( + modalRef.current.querySelectorAll(focusableSelector), + ).filter((element) => !element.hasAttribute("disabled")); + + if (!focusableElements.length) return; + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement as HTMLElement | null; + + if (event.shiftKey && activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + const focusTimeout = setTimeout(focusFirstElement, 0); + document.addEventListener("keydown", keydownHandler); + + return () => { + clearTimeout(focusTimeout); + document.removeEventListener("keydown", keydownHandler); + document.body.style.overflow = previousOverflow; + }; + }, [isOpen, handleClose]); + + const openFilePicker = useCallback(() => { + if (hasAttachmentRef.current) return; + fileInputRef.current?.click(); + }, []); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (isSending) return; + + if (formState.attachment && formState.attachment.size > MAX_ATTACHMENT_BYTES) { + setError(`Attachment must be under ${MAX_ATTACHMENT_SIZE_MB} MB`); + return; + } + + setIsSending(true); + setError(null); + + try { + const formData = new FormData(); + formData.append("type", formState.type); + formData.append("subject", formState.subject); + formData.append("email", formState.email); + formData.append("message", formState.message); + if (formState.attachment) { + formData.append("attachment", formState.attachment); + } + + const res = await fetch("/api/support", { + method: "POST", + body: formData, + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error ?? "Something went wrong"); + return; + } + + setIsSent(true); + } catch { + setError("Network error — please try again"); + } finally { + setIsSending(false); + } + }; + + if (!isOpen) return null; + + return ( +