feat: integrate feedback, broadcast, survey, offline-queue clients + settings page + devops

Phase 4: Add @bytelyst/feedback-client, broadcast-client, survey-client, offline-queue
wrappers. Revamp settings page with profile, password change, feedback form.
Add BroadcastBanner and SurveyBanner to app layout. Wire offline queue flush on boot.

Phase 5: Fix .env.example branding (NoteLett), update docker-compose with all env vars,
enable GitHub Actions CI workflow with lint steps.

Made-with: Cursor
This commit is contained in:
Saravana Achu Mac 2026-03-29 20:57:27 -07:00
parent a5b0a89527
commit 02bcb0d122
15 changed files with 689 additions and 28 deletions

134
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,134 @@
name: CI — NoteLett
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Backend — typecheck + test + build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout common-plat (for @bytelyst/* packages)
uses: actions/checkout@v4
with:
repository: saravanakumardb1/learning_ai_common_plat
path: learning_ai_common_plat
token: ${{ secrets.GH_PAT }}
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Enable pnpm
run: corepack enable
- name: Build @bytelyst/* packages
working-directory: learning_ai_common_plat
run: |
pnpm install --frozen-lockfile
pnpm build
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
- name: Backend lint
run: pnpm --filter @notelett/backend run lint
- name: Backend typecheck
run: pnpm --filter @notelett/backend run typecheck
- name: Backend tests
run: pnpm --filter @notelett/backend run test
env:
DB_PROVIDER: memory
JWT_SECRET: ci-test-secret-at-least-32-characters-long
- name: Backend build
run: pnpm --filter @notelett/backend run build
web:
name: Web — typecheck + test + build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout common-plat (for @bytelyst/* packages)
uses: actions/checkout@v4
with:
repository: saravanakumardb1/learning_ai_common_plat
path: learning_ai_common_plat
token: ${{ secrets.GH_PAT }}
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Enable pnpm
run: corepack enable
- name: Build @bytelyst/* packages
working-directory: learning_ai_common_plat
run: |
pnpm install --frozen-lockfile
pnpm build
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
- name: Web lint
run: pnpm --filter @notelett/web run lint
- name: Web typecheck
run: pnpm --filter @notelett/web run typecheck
- name: Web tests
run: pnpm --filter @notelett/web run test
- name: Web build
run: pnpm --filter @notelett/web run build
mobile:
name: Mobile — typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout common-plat (for @bytelyst/* packages)
uses: actions/checkout@v4
with:
repository: saravanakumardb1/learning_ai_common_plat
path: learning_ai_common_plat
token: ${{ secrets.GH_PAT }}
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Enable pnpm
run: corepack enable
- name: Build @bytelyst/* packages
working-directory: learning_ai_common_plat
run: |
pnpm install --frozen-lockfile
pnpm build
- name: Install workspace dependencies
run: pnpm install --frozen-lockfile
- run: pnpm --filter @notelett/mobile run typecheck

View File

@ -9,6 +9,8 @@ services:
- NODE_ENV=production
- PORT=4016
- HOST=0.0.0.0
- PRODUCT_ID=notelett
- SERVICE_NAME=notelett-backend
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-me}
- COSMOS_ENDPOINT=${COSMOS_ENDPOINT:-}
- COSMOS_KEY=${COSMOS_KEY:-}
@ -17,6 +19,11 @@ services:
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3000}
- PLATFORM_SERVICE_URL=${PLATFORM_SERVICE_URL:-http://localhost:4003}
- EXTRACTION_SERVICE_URL=${EXTRACTION_SERVICE_URL:-http://localhost:4005}
- MCP_SERVER_URL=${MCP_SERVER_URL:-http://localhost:4007}
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-false}
- FEATURE_FLAGS_ENABLED=${FEATURE_FLAGS_ENABLED:-false}
- FIELD_ENCRYPT_ENABLED=${FIELD_ENCRYPT_ENABLED:-false}
- FIELD_ENCRYPT_KEY_PROVIDER=${FIELD_ENCRYPT_KEY_PROVIDER:-memory}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:4016/health"]
@ -32,8 +39,13 @@ services:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_BACKEND_URL=http://backend:4016
- NEXT_PUBLIC_PLATFORM_URL=${PLATFORM_SERVICE_URL:-http://localhost:4003}
- NEXT_PUBLIC_PRODUCT_NAME=NoteLett
- NEXT_PUBLIC_PRODUCT_ID=notelett
- NEXT_PUBLIC_NOTES_API_URL=http://backend:4016/api
- NEXT_PUBLIC_PLATFORM_SERVICE_URL=${PLATFORM_SERVICE_URL:-http://localhost:4003}/api
- NEXT_PUBLIC_EXTRACTION_SERVICE_URL=${EXTRACTION_SERVICE_URL:-http://localhost:4005}
- NEXT_PUBLIC_DIAGNOSTICS_URL=${DIAGNOSTICS_URL:-http://localhost:3000}
- NEXT_PUBLIC_TELEMETRY_TRANSPORT=fetch
depends_on:
backend:
condition: service_healthy

View File

@ -1,6 +1,7 @@
NEXT_PUBLIC_PRODUCT_NAME=ByteLyst Agentic Notes
NEXT_PUBLIC_PRODUCT_ID=agentic-notes
NEXT_PUBLIC_PRODUCT_NAME=NoteLett
NEXT_PUBLIC_PRODUCT_ID=notelett
NEXT_PUBLIC_PLATFORM_SERVICE_URL=http://localhost:4003/api
NEXT_PUBLIC_NOTES_API_URL=http://localhost:4016/api
NEXT_PUBLIC_EXTRACTION_SERVICE_URL=http://localhost:4005
NEXT_PUBLIC_DIAGNOSTICS_URL=http://localhost:3000
NEXT_PUBLIC_TELEMETRY_TRANSPORT=fetch

View File

@ -26,14 +26,18 @@ const nextConfig: NextConfig = {
transpilePackages: [
"@bytelyst/api-client",
"@bytelyst/blob-client",
"@bytelyst/broadcast-client",
"@bytelyst/dashboard-components",
"@bytelyst/design-tokens",
"@bytelyst/diagnostics-client",
"@bytelyst/extraction",
"@bytelyst/feature-flag-client",
"@bytelyst/feedback-client",
"@bytelyst/kill-switch-client",
"@bytelyst/offline-queue",
"@bytelyst/platform-client",
"@bytelyst/react-auth",
"@bytelyst/survey-client",
"@bytelyst/telemetry-client",
],
webpack: (config) => {

View File

@ -22,7 +22,11 @@
"@bytelyst/kill-switch-client": "^0.1.0",
"@bytelyst/platform-client": "^0.1.0",
"@bytelyst/dashboard-components": "^0.1.0",
"@bytelyst/broadcast-client": "^0.1.0",
"@bytelyst/extraction": "^0.1.0",
"@bytelyst/feedback-client": "^0.1.0",
"@bytelyst/offline-queue": "^0.1.0",
"@bytelyst/survey-client": "^0.1.0",
"@tiptap/extension-placeholder": "^2.11.0",
"@tiptap/pm": "^2.11.0",
"@tiptap/react": "^2.11.0",

View File

@ -1,11 +1,15 @@
import type { ReactNode } from "react";
import { AuthGuard } from "@/components/AuthGuard";
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
import { BroadcastBanner } from "@/components/BroadcastBanner";
import { SurveyBanner } from "@/components/SurveyBanner";
export default function ProductLayout({ children }: { children: ReactNode }) {
return (
<AuthGuard>
<KeyboardShortcuts />
<BroadcastBanner />
<SurveyBanner />
<main id="main-content">{children}</main>
</AuthGuard>
);

View File

@ -1,18 +1,82 @@
"use client";
import { useState } from "react";
import { useTheme } from "@/lib/use-theme";
import { useAuth } from "@/lib/auth";
import { AppShell } from "@/components/AppShell";
import { getFeedbackClient } from "@/lib/feedback-client";
import { toast } from "@/lib/toast";
export default function SettingsPage() {
const { theme, toggle } = useTheme();
const { user, logout, changePassword, deleteAccount, isLoading, error, success, clearMessages } = useAuth();
const [feedbackTitle, setFeedbackTitle] = useState("");
const [feedbackBody, setFeedbackBody] = useState("");
const [feedbackType, setFeedbackType] = useState<"bug" | "feature" | "praise" | "other">("bug");
const [submittingFeedback, setSubmittingFeedback] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
async function handleSubmitFeedback() {
if (!feedbackTitle.trim()) return;
setSubmittingFeedback(true);
try {
await getFeedbackClient().submitWithScreenshot({
type: feedbackType,
title: feedbackTitle.trim(),
body: feedbackBody.trim() || undefined,
platform: "web",
});
toast.success("Feedback submitted");
setFeedbackTitle("");
setFeedbackBody("");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit feedback");
} finally {
setSubmittingFeedback(false);
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
clearMessages();
const ok = await changePassword(currentPassword, newPassword);
if (ok) {
setCurrentPassword("");
setNewPassword("");
toast.success("Password changed");
}
}
async function handleDeleteAccount() {
const pw = prompt("Enter your password to confirm account deletion:");
if (!pw) return;
const ok = await deleteAccount(pw);
if (ok) toast.success("Account deleted");
}
return (
<AppShell
title="Settings"
description="Auth/session controls, integration defaults, and workspace-level product preferences."
actions={<div className="badge">Auth fallback active</div>}
description="Account, preferences, feedback, and session management."
actions={
<button onClick={logout} style={{ padding: "6px 14px", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", border: "none", borderRadius: "var(--nl-radius-sm)", fontSize: "var(--nl-fs-sm)" }}>
Sign out
</button>
}
>
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--nl-space-4)", marginBottom: "var(--nl-space-5)" }}>
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", gap: "var(--nl-space-4)" }}>
{/* Profile */}
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Profile</strong>
<div style={{ color: "var(--nl-text-secondary)" }}>
{user?.name ?? "—"} &middot; {user?.email ?? "—"} &middot; {user?.role ?? "—"}
</div>
</article>
{/* Appearance */}
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<strong>Appearance</strong>
@ -21,32 +85,56 @@ export default function SettingsPage() {
<button
onClick={toggle}
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
style={{ padding: "6px 14px", borderRadius: "var(--nl-radius-sm)", border: "1px solid var(--nl-border-default)", background: "var(--nl-surface-muted)", color: "var(--nl-text-primary)", fontSize: 13, fontWeight: 500, cursor: "pointer" }}
className="surface-muted"
style={{ padding: "6px 14px", border: "none", fontSize: "var(--nl-fs-sm)" }}
>
{theme === "dark" ? "☀️ Light" : "🌙 Dark"}
{theme === "dark" ? "Light" : "Dark"}
</button>
</article>
{/* Change password */}
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Change password</strong>
{error && <div style={{ color: "var(--nl-danger, #FF6E6E)", fontSize: "var(--nl-fs-sm)" }}>{error}</div>}
{success && <div style={{ color: "#4ADE80", fontSize: "var(--nl-fs-sm)" }}>{success}</div>}
<form onSubmit={handleChangePassword} style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<input className="input-shell" type="password" placeholder="Current password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required />
<input className="input-shell" type="password" placeholder="New password" minLength={8} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
<button type="submit" disabled={isLoading} style={{ padding: "8px 16px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, justifySelf: "start" }}>
{isLoading ? "Updating…" : "Update password"}
</button>
</form>
</article>
{/* Danger zone */}
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Danger zone</strong>
<button onClick={handleDeleteAccount} style={{ padding: "8px 16px", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, justifySelf: "start" }}>
Delete account
</button>
</article>
</section>
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--nl-space-4)" }}>
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Authentication</strong>
<div style={{ color: "var(--nl-text-secondary)" }}>
Initial shell uses a demo session fallback until platform auth contracts are finalized.
</div>
</article>
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Telemetry & diagnostics</strong>
<div style={{ color: "var(--nl-text-secondary)" }}>
Both clients are initialized on app boot with best-effort defaults and no hard dependency on backend readiness.
</div>
</article>
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong>Deferred configuration</strong>
<div style={{ color: "var(--nl-text-secondary)" }}>
Feature flags, blob upload policies, saved views, and extraction preferences remain follow-up work.
</div>
</article>
{/* Feedback */}
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
<strong>Send feedback</strong>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr", gap: "var(--nl-space-3)" }}>
<select className="input-shell" value={feedbackType} onChange={(e) => setFeedbackType(e.target.value as typeof feedbackType)}>
<option value="bug">Bug</option>
<option value="feature">Feature request</option>
<option value="praise">Praise</option>
<option value="other">Other</option>
</select>
<input className="input-shell" placeholder="Title" value={feedbackTitle} onChange={(e) => setFeedbackTitle(e.target.value)} />
</div>
<textarea className="input-shell" placeholder="Details (optional)" rows={3} value={feedbackBody} onChange={(e) => setFeedbackBody(e.target.value)} style={{ resize: "vertical" }} />
<button
onClick={handleSubmitFeedback}
disabled={submittingFeedback || !feedbackTitle.trim()}
style={{ padding: "8px 16px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, justifySelf: "start" }}
>
{submittingFeedback ? "Sending…" : "Send feedback"}
</button>
</section>
</AppShell>
);

View File

@ -6,11 +6,13 @@ import { Toaster } from "sonner";
import { AuthProvider } from "@/lib/auth";
import { initDiagnostics } from "@/lib/diagnostics";
import { initTelemetry } from "@/lib/telemetry";
import { flushOfflineQueue } from "@/lib/offline-queue";
export function Providers({ children }: { children: ReactNode }) {
useEffect(() => {
initTelemetry();
initDiagnostics();
flushOfflineQueue().catch(() => {});
}, []);
return (

View File

@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
import { getBroadcastClient } from "@/lib/broadcast-client";
interface BroadcastMessage {
id: string;
title: string;
body?: string;
type: "info" | "warning" | "announcement";
actionUrl?: string;
actionLabel?: string;
}
export function BroadcastBanner() {
const [messages, setMessages] = useState<BroadcastMessage[]>([]);
useEffect(() => {
const client = getBroadcastClient();
let stop: (() => void) | undefined;
async function init() {
try {
const list = await client.listMessages();
setMessages(list as BroadcastMessage[]);
stop = client.pollMessages(5 * 60_000, (updated) => {
setMessages(updated as BroadcastMessage[]);
});
} catch {
// silent — broadcast is non-critical
}
}
init();
return () => stop?.();
}, []);
async function dismiss(id: string) {
try {
await getBroadcastClient().markDismissed(id);
} catch {
// best-effort
}
setMessages((prev) => prev.filter((m) => m.id !== id));
}
async function handleClick(msg: BroadcastMessage) {
try {
await getBroadcastClient().trackClick(msg.id);
} catch {
// best-effort
}
if (msg.actionUrl) window.open(msg.actionUrl, "_blank", "noopener");
}
if (messages.length === 0) return null;
return (
<div style={{ display: "grid", gap: "var(--nl-space-2)", padding: "var(--nl-space-3) var(--nl-space-4)" }}>
{messages.map((msg) => (
<div
key={msg.id}
role="status"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "var(--nl-space-3) var(--nl-space-4)",
background: msg.type === "warning" ? "rgba(255,180,70,0.12)" : "rgba(100,160,255,0.10)",
borderRadius: "var(--nl-radius-sm)",
fontSize: "var(--nl-fs-sm)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-3)", flex: 1, minWidth: 0 }}>
<strong style={{ whiteSpace: "nowrap" }}>{msg.title}</strong>
{msg.body && <span style={{ color: "var(--nl-text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{msg.body}</span>}
{msg.actionUrl && (
<button onClick={() => handleClick(msg)} style={{ background: "none", border: "none", color: "var(--nl-accent-primary)", cursor: "pointer", fontWeight: 600, whiteSpace: "nowrap" }}>
{msg.actionLabel ?? "Learn more"}
</button>
)}
</div>
<button onClick={() => dismiss(msg.id)} aria-label="Dismiss" style={{ background: "none", border: "none", color: "var(--nl-text-secondary)", cursor: "pointer", fontSize: 16, lineHeight: 1, padding: "0 0 0 var(--nl-space-3)" }}>
&times;
</button>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,162 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { getSurveyClient } from "@/lib/survey-client";
import { toast } from "@/lib/toast";
interface SurveyQuestion {
id: string;
text: string;
type: "text" | "rating" | "choice";
options?: string[];
}
interface ActiveSurvey {
id: string;
title: string;
questions: SurveyQuestion[];
}
export function SurveyBanner() {
const [survey, setSurvey] = useState<ActiveSurvey | null>(null);
const [currentIdx, setCurrentIdx] = useState(0);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [started, setStarted] = useState(false);
useEffect(() => {
const client = getSurveyClient();
let stop: (() => void) | undefined;
async function init() {
try {
const active = await client.getActiveSurvey();
if (active) setSurvey(active as ActiveSurvey);
stop = client.pollSurveys(10 * 60_000, (s) => {
if (s && !survey) setSurvey(s as ActiveSurvey);
});
} catch {
// silent — surveys are non-critical
}
}
init();
return () => stop?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const dismiss = useCallback(async () => {
if (!survey) return;
try { await getSurveyClient().dismissSurvey(survey.id); } catch { /* best-effort */ }
setSurvey(null);
}, [survey]);
async function handleStart() {
if (!survey) return;
try { await getSurveyClient().startSurvey(survey.id); } catch { /* best-effort */ }
setStarted(true);
}
async function handleAnswer(questionId: string, value: string) {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
try { await getSurveyClient().submitAnswer(survey!.id, questionId, value); } catch { /* best-effort */ }
}
async function handleNext() {
if (!survey) return;
if (currentIdx < survey.questions.length - 1) {
setCurrentIdx((i) => i + 1);
} else {
try { await getSurveyClient().completeSurvey(survey.id); } catch { /* best-effort */ }
toast.success("Thanks for your feedback!");
setSurvey(null);
}
}
if (!survey) return null;
if (!started) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "var(--nl-space-3) var(--nl-space-4)", background: "rgba(100,200,120,0.10)", borderRadius: "var(--nl-radius-sm)", fontSize: "var(--nl-fs-sm)" }}>
<span><strong>{survey.title}</strong> Quick survey ({survey.questions.length} questions)</span>
<div style={{ display: "flex", gap: "var(--nl-space-3)" }}>
<button onClick={handleStart} style={{ background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", padding: "4px 12px", fontWeight: 600, cursor: "pointer" }}>Start</button>
<button onClick={dismiss} style={{ background: "none", border: "none", color: "var(--nl-text-secondary)", cursor: "pointer" }}>&times;</button>
</div>
</div>
);
}
const question = survey.questions[currentIdx];
if (!question) return null;
return (
<div style={{ padding: "var(--nl-space-4)", background: "rgba(100,200,120,0.08)", borderRadius: "var(--nl-radius-sm)", fontSize: "var(--nl-fs-sm)" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "var(--nl-space-3)" }}>
<strong>{question.text}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{currentIdx + 1}/{survey.questions.length}</span>
</div>
{question.type === "text" && (
<input
className="input-shell"
placeholder="Your answer…"
value={answers[question.id] ?? ""}
onChange={(e) => handleAnswer(question.id, e.target.value)}
/>
)}
{question.type === "rating" && (
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => handleAnswer(question.id, String(n))}
style={{
width: 36, height: 36,
borderRadius: "var(--nl-radius-sm)",
border: answers[question.id] === String(n) ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
background: answers[question.id] === String(n) ? "var(--nl-accent-primary)" : "transparent",
color: answers[question.id] === String(n) ? "#fff" : "var(--nl-text-primary)",
cursor: "pointer", fontWeight: 600,
}}
>
{n}
</button>
))}
</div>
)}
{question.type === "choice" && question.options && (
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{question.options.map((opt) => (
<button
key={opt}
onClick={() => handleAnswer(question.id, opt)}
style={{
padding: "4px 12px",
borderRadius: "var(--nl-radius-sm)",
border: answers[question.id] === opt ? "2px solid var(--nl-accent-primary)" : "1px solid var(--nl-border-default)",
background: answers[question.id] === opt ? "rgba(var(--nl-accent-rgb, 100,160,255), 0.15)" : "transparent",
cursor: "pointer",
}}
>
{opt}
</button>
))}
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "var(--nl-space-3)", gap: "var(--nl-space-2)" }}>
<button onClick={dismiss} style={{ background: "none", border: "none", color: "var(--nl-text-secondary)", cursor: "pointer" }}>Dismiss</button>
<button
onClick={handleNext}
disabled={!answers[question.id]}
style={{ padding: "4px 14px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, cursor: "pointer" }}
>
{currentIdx < survey.questions.length - 1 ? "Next" : "Submit"}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
"use client";
import { createBroadcastClient, type BroadcastClient } from "@bytelyst/broadcast-client";
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
function getAccessToken(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem(`${PRODUCT_ID}_access_token`) ?? "";
}
let _client: BroadcastClient | null = null;
export function getBroadcastClient(): BroadcastClient {
if (!_client) {
_client = createBroadcastClient({
baseUrl: PLATFORM_SERVICE_URL,
productId: PRODUCT_ID,
getAuthToken: getAccessToken,
platform: "web",
appVersion: "0.1.0",
osVersion: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 40) : "unknown",
});
}
return _client;
}

View File

@ -0,0 +1,20 @@
"use client";
import { createFeedbackClient, type FeedbackClient } from "@bytelyst/feedback-client";
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
function getAccessToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(`${PRODUCT_ID}_access_token`);
}
let _client: FeedbackClient | null = null;
export function getFeedbackClient(): FeedbackClient {
if (!_client) {
_client = createFeedbackClient({
baseUrl: PLATFORM_SERVICE_URL,
getAuthToken: () => getAccessToken() ?? "",
});
}
return _client;
}

View File

@ -0,0 +1,36 @@
"use client";
import { createOfflineQueue, type OfflineQueue } from "@bytelyst/offline-queue";
import { PRODUCT_ID } from "@/lib/product-config";
import { createNotesApiClient } from "@/lib/api-helpers";
const ACTION_METHOD: Record<string, string> = {
create: "POST",
update: "PATCH",
delete: "DELETE",
};
let _queue: OfflineQueue | null = null;
export function getOfflineQueue(): OfflineQueue {
if (!_queue) {
_queue = createOfflineQueue({
storageKey: `${PRODUCT_ID}_offline_queue`,
storage: typeof window !== "undefined" ? localStorage : { getItem: () => null, setItem: () => {} },
});
}
return _queue;
}
export async function flushOfflineQueue(): Promise<{ flushed: number; failed: number }> {
const queue = getOfflineQueue();
const api = createNotesApiClient();
return queue.flush(async (action, path, payload) => {
const method = ACTION_METHOD[action] ?? "POST";
await api.fetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
});
}

View File

@ -0,0 +1,55 @@
"use client";
import { createPlatformClient, type PlatformClient } from "@bytelyst/platform-client";
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
function getAccessToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(`${PRODUCT_ID}_access_token`);
}
let _client: PlatformClient | null = null;
function getClient(): PlatformClient {
if (!_client) {
_client = createPlatformClient({
baseUrl: PLATFORM_SERVICE_URL,
productId: PRODUCT_ID,
getAccessToken,
});
}
return _client;
}
export interface UserSettings {
theme?: "dark" | "light" | "system";
language?: string;
notificationsEnabled?: boolean;
[key: string]: unknown;
}
export interface ActiveSession {
id: string;
deviceName: string;
lastActiveAt: string;
isCurrent: boolean;
}
export async function getUserSettings(): Promise<UserSettings> {
return getClient().get<UserSettings>("/settings");
}
export async function updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings> {
return getClient().put<UserSettings>("/settings", settings);
}
export async function listSessions(): Promise<ActiveSession[]> {
return getClient().get<ActiveSession[]>("/sessions");
}
export async function revokeSession(sessionId: string): Promise<void> {
await getClient().del(`/sessions/${sessionId}`);
}
export async function updateProfile(updates: { displayName?: string }): Promise<void> {
await getClient().put("/auth/profile", updates);
}

View File

@ -0,0 +1,24 @@
"use client";
import { createSurveyClient, type SurveyClient } from "@bytelyst/survey-client";
import { PLATFORM_SERVICE_URL, PRODUCT_ID } from "@/lib/product-config";
function getAccessToken(): string {
if (typeof window === "undefined") return "";
return localStorage.getItem(`${PRODUCT_ID}_access_token`) ?? "";
}
let _client: SurveyClient | null = null;
export function getSurveyClient(): SurveyClient {
if (!_client) {
_client = createSurveyClient({
baseUrl: PLATFORM_SERVICE_URL,
productId: PRODUCT_ID,
getAuthToken: getAccessToken,
platform: "web",
appVersion: "0.1.0",
osVersion: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 40) : "unknown",
});
}
return _client;
}