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:
parent
a5b0a89527
commit
02bcb0d122
134
.github/workflows/ci.yml
vendored
Normal file
134
.github/workflows/ci.yml
vendored
Normal 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
|
||||||
@ -9,6 +9,8 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=4016
|
- PORT=4016
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
|
- PRODUCT_ID=notelett
|
||||||
|
- SERVICE_NAME=notelett-backend
|
||||||
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-me}
|
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-me}
|
||||||
- COSMOS_ENDPOINT=${COSMOS_ENDPOINT:-}
|
- COSMOS_ENDPOINT=${COSMOS_ENDPOINT:-}
|
||||||
- COSMOS_KEY=${COSMOS_KEY:-}
|
- COSMOS_KEY=${COSMOS_KEY:-}
|
||||||
@ -17,6 +19,11 @@ services:
|
|||||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3000}
|
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3000}
|
||||||
- PLATFORM_SERVICE_URL=${PLATFORM_SERVICE_URL:-http://localhost:4003}
|
- PLATFORM_SERVICE_URL=${PLATFORM_SERVICE_URL:-http://localhost:4003}
|
||||||
- EXTRACTION_SERVICE_URL=${EXTRACTION_SERVICE_URL:-http://localhost:4005}
|
- 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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:4016/health"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:4016/health"]
|
||||||
@ -32,8 +39,13 @@ services:
|
|||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NEXT_PUBLIC_BACKEND_URL=http://backend:4016
|
- NEXT_PUBLIC_PRODUCT_NAME=NoteLett
|
||||||
- NEXT_PUBLIC_PLATFORM_URL=${PLATFORM_SERVICE_URL:-http://localhost:4003}
|
- 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:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
NEXT_PUBLIC_PRODUCT_NAME=ByteLyst Agentic Notes
|
NEXT_PUBLIC_PRODUCT_NAME=NoteLett
|
||||||
NEXT_PUBLIC_PRODUCT_ID=agentic-notes
|
NEXT_PUBLIC_PRODUCT_ID=notelett
|
||||||
NEXT_PUBLIC_PLATFORM_SERVICE_URL=http://localhost:4003/api
|
NEXT_PUBLIC_PLATFORM_SERVICE_URL=http://localhost:4003/api
|
||||||
NEXT_PUBLIC_NOTES_API_URL=http://localhost:4016/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_DIAGNOSTICS_URL=http://localhost:3000
|
||||||
NEXT_PUBLIC_TELEMETRY_TRANSPORT=fetch
|
NEXT_PUBLIC_TELEMETRY_TRANSPORT=fetch
|
||||||
|
|||||||
@ -26,14 +26,18 @@ const nextConfig: NextConfig = {
|
|||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@bytelyst/api-client",
|
"@bytelyst/api-client",
|
||||||
"@bytelyst/blob-client",
|
"@bytelyst/blob-client",
|
||||||
|
"@bytelyst/broadcast-client",
|
||||||
"@bytelyst/dashboard-components",
|
"@bytelyst/dashboard-components",
|
||||||
"@bytelyst/design-tokens",
|
"@bytelyst/design-tokens",
|
||||||
"@bytelyst/diagnostics-client",
|
"@bytelyst/diagnostics-client",
|
||||||
"@bytelyst/extraction",
|
"@bytelyst/extraction",
|
||||||
"@bytelyst/feature-flag-client",
|
"@bytelyst/feature-flag-client",
|
||||||
|
"@bytelyst/feedback-client",
|
||||||
"@bytelyst/kill-switch-client",
|
"@bytelyst/kill-switch-client",
|
||||||
|
"@bytelyst/offline-queue",
|
||||||
"@bytelyst/platform-client",
|
"@bytelyst/platform-client",
|
||||||
"@bytelyst/react-auth",
|
"@bytelyst/react-auth",
|
||||||
|
"@bytelyst/survey-client",
|
||||||
"@bytelyst/telemetry-client",
|
"@bytelyst/telemetry-client",
|
||||||
],
|
],
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
|
|||||||
@ -22,7 +22,11 @@
|
|||||||
"@bytelyst/kill-switch-client": "^0.1.0",
|
"@bytelyst/kill-switch-client": "^0.1.0",
|
||||||
"@bytelyst/platform-client": "^0.1.0",
|
"@bytelyst/platform-client": "^0.1.0",
|
||||||
"@bytelyst/dashboard-components": "^0.1.0",
|
"@bytelyst/dashboard-components": "^0.1.0",
|
||||||
|
"@bytelyst/broadcast-client": "^0.1.0",
|
||||||
"@bytelyst/extraction": "^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/extension-placeholder": "^2.11.0",
|
||||||
"@tiptap/pm": "^2.11.0",
|
"@tiptap/pm": "^2.11.0",
|
||||||
"@tiptap/react": "^2.11.0",
|
"@tiptap/react": "^2.11.0",
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AuthGuard } from "@/components/AuthGuard";
|
import { AuthGuard } from "@/components/AuthGuard";
|
||||||
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
|
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
|
||||||
|
import { BroadcastBanner } from "@/components/BroadcastBanner";
|
||||||
|
import { SurveyBanner } from "@/components/SurveyBanner";
|
||||||
|
|
||||||
export default function ProductLayout({ children }: { children: ReactNode }) {
|
export default function ProductLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<KeyboardShortcuts />
|
<KeyboardShortcuts />
|
||||||
|
<BroadcastBanner />
|
||||||
|
<SurveyBanner />
|
||||||
<main id="main-content">{children}</main>
|
<main id="main-content">{children}</main>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,18 +1,82 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useTheme } from "@/lib/use-theme";
|
import { useTheme } from "@/lib/use-theme";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { getFeedbackClient } from "@/lib/feedback-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { theme, toggle } = useTheme();
|
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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Settings"
|
title="Settings"
|
||||||
description="Auth/session controls, integration defaults, and workspace-level product preferences."
|
description="Account, preferences, feedback, and session management."
|
||||||
actions={<div className="badge">Auth fallback active</div>}
|
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 ?? "—"} · {user?.email ?? "—"} · {user?.role ?? "—"}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
<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)" }}>
|
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
||||||
<strong>Appearance</strong>
|
<strong>Appearance</strong>
|
||||||
@ -21,32 +85,56 @@ export default function SettingsPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
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>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--nl-space-4)" }}>
|
{/* Feedback */}
|
||||||
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
|
||||||
<strong>Authentication</strong>
|
<strong>Send feedback</strong>
|
||||||
<div style={{ color: "var(--nl-text-secondary)" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr", gap: "var(--nl-space-3)" }}>
|
||||||
Initial shell uses a demo session fallback until platform auth contracts are finalized.
|
<select className="input-shell" value={feedbackType} onChange={(e) => setFeedbackType(e.target.value as typeof feedbackType)}>
|
||||||
</div>
|
<option value="bug">Bug</option>
|
||||||
</article>
|
<option value="feature">Feature request</option>
|
||||||
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
<option value="praise">Praise</option>
|
||||||
<strong>Telemetry & diagnostics</strong>
|
<option value="other">Other</option>
|
||||||
<div style={{ color: "var(--nl-text-secondary)" }}>
|
</select>
|
||||||
Both clients are initialized on app boot with best-effort defaults and no hard dependency on backend readiness.
|
<input className="input-shell" placeholder="Title" value={feedbackTitle} onChange={(e) => setFeedbackTitle(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<textarea className="input-shell" placeholder="Details (optional)" rows={3} value={feedbackBody} onChange={(e) => setFeedbackBody(e.target.value)} style={{ resize: "vertical" }} />
|
||||||
<article className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
<button
|
||||||
<strong>Deferred configuration</strong>
|
onClick={handleSubmitFeedback}
|
||||||
<div style={{ color: "var(--nl-text-secondary)" }}>
|
disabled={submittingFeedback || !feedbackTitle.trim()}
|
||||||
Feature flags, blob upload policies, saved views, and extraction preferences remain follow-up work.
|
style={{ padding: "8px 16px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600, justifySelf: "start" }}
|
||||||
</div>
|
>
|
||||||
</article>
|
{submittingFeedback ? "Sending…" : "Send feedback"}
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,11 +6,13 @@ import { Toaster } from "sonner";
|
|||||||
import { AuthProvider } from "@/lib/auth";
|
import { AuthProvider } from "@/lib/auth";
|
||||||
import { initDiagnostics } from "@/lib/diagnostics";
|
import { initDiagnostics } from "@/lib/diagnostics";
|
||||||
import { initTelemetry } from "@/lib/telemetry";
|
import { initTelemetry } from "@/lib/telemetry";
|
||||||
|
import { flushOfflineQueue } from "@/lib/offline-queue";
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initTelemetry();
|
initTelemetry();
|
||||||
initDiagnostics();
|
initDiagnostics();
|
||||||
|
flushOfflineQueue().catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
91
web/src/components/BroadcastBanner.tsx
Normal file
91
web/src/components/BroadcastBanner.tsx
Normal 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)" }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
web/src/components/SurveyBanner.tsx
Normal file
162
web/src/components/SurveyBanner.tsx
Normal 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" }}>×</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
web/src/lib/broadcast-client.ts
Normal file
24
web/src/lib/broadcast-client.ts
Normal 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;
|
||||||
|
}
|
||||||
20
web/src/lib/feedback-client.ts
Normal file
20
web/src/lib/feedback-client.ts
Normal 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;
|
||||||
|
}
|
||||||
36
web/src/lib/offline-queue.ts
Normal file
36
web/src/lib/offline-queue.ts
Normal 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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
55
web/src/lib/platform-api.ts
Normal file
55
web/src/lib/platform-api.ts
Normal 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);
|
||||||
|
}
|
||||||
24
web/src/lib/survey-client.ts
Normal file
24
web/src/lib/survey-client.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user