forked from Goutam/lynkeduppro-crm
Merge remote-tracking branch 'origin/goutamnextflow' into feat/projects
# Conflicts: # src/app/dashboard/dashboard.css # src/components/dashboard/dashboard.tsx # src/components/dashboard/ui.tsx
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Shared entry gate for every /api/gallery/ai/* route: authenticate, then
|
||||
* throttle. Lives in a `_`-prefixed file so the App Router never treats it as a
|
||||
* route (only `route.ts` defines an endpoint).
|
||||
*
|
||||
* Order matters: we authenticate FIRST so the rate limit can be keyed by
|
||||
* principal rather than by a spoofable `x-forwarded-for` hop wherever possible.
|
||||
* The session check is one cheap BFF round trip; the work it guards is a GPU
|
||||
* call, so paying it before throttling is the right trade.
|
||||
*
|
||||
* SERVER-ONLY.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { limit } from "@/lib/server/rate-limit";
|
||||
import { rateLimitKey, requireGallerySession } from "@/lib/server/session";
|
||||
|
||||
/** Per-minute budgets, per the Smart Gallery route contract. */
|
||||
export const RATE_LIMITS = {
|
||||
classify: 30,
|
||||
edit: 12,
|
||||
tilt: 30,
|
||||
transcribe: 20,
|
||||
denoise: 20,
|
||||
} as const;
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
export type GuardResult =
|
||||
| { ok: true; principalId?: string }
|
||||
/** Ready-to-return error response — the route should return it unchanged. */
|
||||
| { ok: false; response: NextResponse };
|
||||
|
||||
/**
|
||||
* @param route Which budget to apply (also namespaces the limiter key so a
|
||||
* caller's `edit` spend does not consume their `classify` budget).
|
||||
*/
|
||||
export async function guard(req: Request, route: keyof typeof RATE_LIMITS): Promise<GuardResult> {
|
||||
const session = await requireGallerySession(req);
|
||||
if (!session.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: session.error }, { status: session.status }),
|
||||
};
|
||||
}
|
||||
|
||||
const key = `${route}:${rateLimitKey(req, session.principalId)}`;
|
||||
const { ok, retryAfter } = limit(key, RATE_LIMITS[route], WINDOW_MS);
|
||||
if (!ok) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: "Too many requests — slow down." },
|
||||
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, principalId: session.principalId };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import { rpDetect } from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Object-detection proxy for the RunPod YOLO construction-material classifier (#1).
|
||||
* The key + endpoint URL stay server-side. The client calls this only when
|
||||
* NEXT_PUBLIC_APG_RUNPOD_DETECT is on; otherwise detection runs fully in-browser
|
||||
* (COCO-SSD) with no server round-trip. Returns the SDK's DetectedObject[] shape
|
||||
* (box as 0..1 fractions) so it drops straight into the Objects browser / smart
|
||||
* albums / search.
|
||||
*
|
||||
* POST { imageBase64, width, height } -> { objects: [{ label, confidence, box }] }
|
||||
* Auth: session-gated (see lib/server/session.ts). Rate limit: 30/min.
|
||||
*/
|
||||
|
||||
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — under serverless body limits
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "classify");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
if (!process.env.RUNPOD_API_KEY || !process.env.RUNPOD_YOLO_URL) {
|
||||
return NextResponse.json(
|
||||
{ error: "RunPod detection is not configured (set RUNPOD_API_KEY + RUNPOD_YOLO_URL)." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { imageBase64, width, height } = (body ?? {}) as {
|
||||
imageBase64?: unknown;
|
||||
width?: unknown;
|
||||
height?: unknown;
|
||||
};
|
||||
if (
|
||||
typeof imageBase64 !== "string" ||
|
||||
imageBase64.length === 0 ||
|
||||
imageBase64.length > MAX_BASE64
|
||||
) {
|
||||
return NextResponse.json({ error: "Invalid or oversized image." }, { status: 400 });
|
||||
}
|
||||
const w = Number(width);
|
||||
const h = Number(height);
|
||||
|
||||
try {
|
||||
const objects = await rpDetect(
|
||||
imageBase64,
|
||||
Number.isFinite(w) && w > 0 ? w : 1,
|
||||
Number.isFinite(h) && h > 0 ? h : 1,
|
||||
);
|
||||
return NextResponse.json({ objects });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Detection failed.";
|
||||
const status = err instanceof RunpodError ? err.status : 502;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import { rpDenoiseAudio } from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60; // cold-start denoise worker can take a while
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Audio noise-removal proxy. Accepts base64 WAV (48 kHz mono PCM16, produced
|
||||
* in-browser) and returns a cleaned base64 WAV. Calls the RunPod audio-denoise
|
||||
* endpoint (RUNPOD_AUDIO_DENOISE_URL) — key stays server-side. Used before
|
||||
* transcription on noisy sites.
|
||||
*
|
||||
* POST { audio } -> { audio }
|
||||
* Auth: session-gated. Rate limit: 20/min.
|
||||
*/
|
||||
|
||||
const MAX_BASE64 = 12_000_000; // ~9 MB decoded WAV
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "denoise");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
let body: { audio?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const audio = typeof body.audio === "string" ? body.audio : "";
|
||||
if (!audio) return NextResponse.json({ error: "Missing audio." }, { status: 400 });
|
||||
if (audio.length > MAX_BASE64) {
|
||||
return NextResponse.json({ error: "Audio too long — keep it under ~30s." }, { status: 413 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { audioB64 } = await rpDenoiseAudio(audio);
|
||||
return NextResponse.json({ audio: audioB64 });
|
||||
} catch (e) {
|
||||
const msg = e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Denoise failed.";
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import {
|
||||
rpImg2Img,
|
||||
rpInpaint,
|
||||
rpRemoveBackground,
|
||||
rpUpscale,
|
||||
} from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60; // SD / cold-start models can take a while
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Generative image-edit proxy. The BACKEND is pluggable — pick one with env
|
||||
* `AI_EDIT_PROVIDER` (default `auto`):
|
||||
*
|
||||
* - `runpod` → RunPod serverless GPU endpoints (one per model). Maps each
|
||||
* op → endpoint: restore/upscale → Real-ESRGAN (#7), colorize
|
||||
* → img2img (#10), replace-sky / magic-eraser / generative-fill
|
||||
* → SD 3.5 masked inpaint (#9), prompt → SD 3.5 img2img (#10).
|
||||
* Env: RUNPOD_API_KEY + per-model RUNPOD_*_URL. Key stays
|
||||
* server-side.
|
||||
* - `local` → your own Stable Diffusion server (Automatic1111 / Forge /
|
||||
* SD.Next img2img API). Env: LOCAL_SD_URL.
|
||||
* - `huggingface` → Hugging Face Inference API. Env: HF_API_TOKEN, HF_IMAGE_MODEL.
|
||||
* - `gemini` → Google Gemini image model (needs a billed key for image output).
|
||||
* Env: GEMINI_API_KEY, GEMINI_IMAGE_MODEL.
|
||||
* - `auto` → first configured of: runpod → local → huggingface → gemini.
|
||||
*
|
||||
* NOTE: `remove-background` runs in-browser by default (@imgly, no key), so it
|
||||
* usually never reaches here. Object detection uses its own route (./classify).
|
||||
*
|
||||
* POST { imageBase64, mimeType?, op, maskBase64?, params? } -> { imageBase64, mimeType }
|
||||
* Auth: session-gated. Rate limit: 12/min (the most expensive route).
|
||||
*/
|
||||
|
||||
const OP_PROMPTS: Record<string, string> = {
|
||||
restore:
|
||||
"Restore and enhance this photograph: improve sharpness and clarity, correct exposure and white balance, reduce noise and compression artifacts, recover detail. Keep it natural and photorealistic.",
|
||||
colorize: "Colorize this image with natural, realistic, well-balanced colors.",
|
||||
"replace-sky":
|
||||
"Replace the sky with a dramatic, beautiful golden-hour sky with soft clouds. Keep the foreground subject unchanged and the result photorealistic.",
|
||||
};
|
||||
|
||||
const MAX_BASE64 = 4_000_000; // ~3 MB decoded — stays under serverless body limits
|
||||
|
||||
type Provider = "runpod" | "local" | "huggingface" | "gemini" | "none";
|
||||
|
||||
function resolveProvider(): Provider {
|
||||
const explicit = (process.env.AI_EDIT_PROVIDER || "auto").toLowerCase();
|
||||
if (
|
||||
explicit === "runpod" ||
|
||||
explicit === "local" ||
|
||||
explicit === "huggingface" ||
|
||||
explicit === "gemini"
|
||||
)
|
||||
return explicit;
|
||||
if (explicit === "none") return "none";
|
||||
// auto: prefer RunPod GPU endpoints, then a private local server, then HF, then Gemini.
|
||||
// Detect RunPod when the key + ANY image endpoint URL is set (an upscale/colorize-only
|
||||
// deployment is valid — not just the SD ones).
|
||||
if (
|
||||
process.env.RUNPOD_API_KEY &&
|
||||
(process.env.RUNPOD_SD_IMG2IMG_URL ||
|
||||
process.env.RUNPOD_SD_INPAINT_URL ||
|
||||
process.env.RUNPOD_UPSCALE_URL ||
|
||||
process.env.RUNPOD_COLORIZE_URL ||
|
||||
process.env.RUNPOD_BG_REMOVE_URL)
|
||||
)
|
||||
return "runpod";
|
||||
if (process.env.LOCAL_SD_URL) return "local";
|
||||
if (process.env.HF_API_TOKEN) return "huggingface";
|
||||
if (process.env.GEMINI_API_KEY) return "gemini";
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** Ops that only the RunPod (mask/fixed-function) backend can serve. */
|
||||
const RUNPOD_ONLY_OPS = new Set(["upscale", "magic-eraser", "generative-fill"]);
|
||||
|
||||
interface EditResult {
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "edit");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
const provider = resolveProvider();
|
||||
if (provider === "none") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"AI image editing is not configured. Set AI_EDIT_PROVIDER=runpod + RUNPOD_API_KEY + the per-model RUNPOD_*_URL vars (RunPod GPU), or LOCAL_SD_URL (own Stable Diffusion), HF_API_TOKEN (Hugging Face), or GEMINI_API_KEY. Background removal and all analysis still work with no key.",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { imageBase64, mimeType, op, maskBase64, params } = (body ?? {}) as {
|
||||
imageBase64?: unknown;
|
||||
mimeType?: unknown;
|
||||
op?: { type?: string; prompt?: string; factor?: number };
|
||||
maskBase64?: unknown;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
if (typeof imageBase64 !== "string" || imageBase64.length === 0) {
|
||||
return NextResponse.json({ error: "Invalid image." }, { status: 400 });
|
||||
}
|
||||
const hasMask = typeof maskBase64 === "string" && maskBase64.length > 0;
|
||||
// Image + mask share one request body — budget them together against the cap.
|
||||
if (imageBase64.length + (hasMask ? (maskBase64 as string).length : 0) > MAX_BASE64) {
|
||||
return NextResponse.json(
|
||||
{ error: "Image (plus mask) is too large — try a smaller image." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const safeMime =
|
||||
typeof mimeType === "string" && /^image\/(jpeg|png|webp)$/.test(mimeType)
|
||||
? mimeType
|
||||
: "image/jpeg";
|
||||
|
||||
const opType = op?.type ?? "";
|
||||
if (provider !== "runpod" && RUNPOD_ONLY_OPS.has(opType)) {
|
||||
return NextResponse.json(
|
||||
{ error: "This edit needs the RunPod backend (set AI_EDIT_PROVIDER=runpod)." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build the instruction from an allow-listed op (never trust arbitrary server prompts).
|
||||
let instruction = "";
|
||||
if (opType === "prompt" || opType === "generative-fill") {
|
||||
const p = typeof op?.prompt === "string" ? op.prompt.trim() : "";
|
||||
if (!p) return NextResponse.json({ error: "Empty prompt." }, { status: 400 });
|
||||
instruction = p.slice(0, 500);
|
||||
} else if (opType === "replace-sky") {
|
||||
instruction =
|
||||
typeof op?.prompt === "string" && op.prompt.trim()
|
||||
? `Replace the sky with: ${op.prompt.trim().slice(0, 300)}. Keep the foreground unchanged and photorealistic.`
|
||||
: OP_PROMPTS["replace-sky"]!;
|
||||
} else if (opType === "magic-eraser") {
|
||||
instruction =
|
||||
"Fill the selected region with a clean, seamless, plausible background. Photorealistic.";
|
||||
} else if (OP_PROMPTS[opType]) {
|
||||
instruction = OP_PROMPTS[opType]!;
|
||||
} else if (opType !== "upscale" && opType !== "remove-background") {
|
||||
return NextResponse.json({ error: "Unsupported operation." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
let result: EditResult;
|
||||
if (provider === "runpod")
|
||||
result = await editRunPod(
|
||||
op ?? {},
|
||||
imageBase64,
|
||||
instruction,
|
||||
hasMask ? (maskBase64 as string) : undefined,
|
||||
params,
|
||||
);
|
||||
else if (provider === "local") result = await editLocal(instruction, imageBase64);
|
||||
else if (provider === "huggingface") result = await editHuggingFace(instruction, imageBase64);
|
||||
else result = await editGemini(instruction, imageBase64, safeMime);
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "AI request failed.";
|
||||
const status = err instanceof AiError || err instanceof RunpodError ? err.status : 502;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: RunPod serverless GPU endpoints (one model per endpoint).
|
||||
// Each op maps to its endpoint; the API key + URLs stay server-side.
|
||||
// ---------------------------------------------------------------------------
|
||||
interface SdParams {
|
||||
negativePrompt?: string;
|
||||
strength?: number;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
guidanceScale?: number;
|
||||
}
|
||||
|
||||
function sanitizeParams(raw: unknown): SdParams {
|
||||
const p = (raw ?? {}) as Record<string, unknown>;
|
||||
const out: SdParams = {};
|
||||
if (typeof p.negativePrompt === "string" && p.negativePrompt.trim())
|
||||
out.negativePrompt = p.negativePrompt.trim().slice(0, 300);
|
||||
const strength = Number(p.strength);
|
||||
if (Number.isFinite(strength)) out.strength = Math.max(0, Math.min(1, strength));
|
||||
const steps = Number(p.steps);
|
||||
if (Number.isFinite(steps)) out.steps = Math.max(1, Math.min(60, Math.round(steps)));
|
||||
const guidance = Number(p.guidanceScale);
|
||||
if (Number.isFinite(guidance)) out.guidanceScale = Math.max(1, Math.min(20, guidance));
|
||||
const seed = Number(p.seed);
|
||||
if (Number.isFinite(seed)) out.seed = Math.max(0, Math.min(2_147_483_647, Math.round(seed)));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function editRunPod(
|
||||
op: { type?: string; prompt?: string; factor?: number },
|
||||
imageBase64: string,
|
||||
instruction: string,
|
||||
maskBase64: string | undefined,
|
||||
rawParams: unknown,
|
||||
): Promise<EditResult> {
|
||||
const params = sanitizeParams(rawParams);
|
||||
switch (op.type) {
|
||||
case "remove-background":
|
||||
// U²-Net via rembg (#6) — a real endpoint replacing the flaky in-browser remover.
|
||||
return rpRemoveBackground(imageBase64);
|
||||
case "restore":
|
||||
// Real-ESRGAN (#7) with the GFPGAN face pass = "Restore & Enhance".
|
||||
return rpUpscale(imageBase64, 4, true);
|
||||
case "upscale":
|
||||
return rpUpscale(imageBase64, op.factor === 4 ? 4 : 2, false);
|
||||
case "colorize":
|
||||
// The dedicated DDColor endpoint kept hard-crashing (modelscope). Route
|
||||
// colorize through the img2img model as an instruction instead.
|
||||
return rpImg2Img({ imageB64: imageBase64, prompt: instruction, ...params });
|
||||
case "prompt":
|
||||
return rpImg2Img({ imageB64: imageBase64, prompt: instruction, ...params }); // SD 3.5 img2img (#10)
|
||||
case "replace-sky":
|
||||
// True sky replacement is masked inpaint (#9). Without a mask (no in-app sky
|
||||
// segmentation yet) degrade to a low-strength img2img (#10) so the foreground
|
||||
// is mostly preserved.
|
||||
if (maskBase64)
|
||||
return rpInpaint({
|
||||
imageB64: imageBase64,
|
||||
maskB64: maskBase64,
|
||||
prompt: instruction,
|
||||
...params,
|
||||
});
|
||||
return rpImg2Img({
|
||||
imageB64: imageBase64,
|
||||
prompt: instruction,
|
||||
...params,
|
||||
strength: params.strength ?? 0.4,
|
||||
});
|
||||
case "magic-eraser":
|
||||
case "generative-fill":
|
||||
// SD 3.5 masked inpaint (#9) — white in the mask = the region to regenerate.
|
||||
if (!maskBase64) throw new AiError("This edit needs a mask/selection.", 400);
|
||||
return rpInpaint({
|
||||
imageB64: imageBase64,
|
||||
maskB64: maskBase64,
|
||||
prompt: instruction,
|
||||
...params,
|
||||
});
|
||||
default:
|
||||
throw new AiError("Unsupported operation.", 400);
|
||||
}
|
||||
}
|
||||
|
||||
class AiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 502) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: local Stable Diffusion (Automatic1111 / Forge / SD.Next img2img API)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editLocal(instruction: string, imageBase64: string): Promise<EditResult> {
|
||||
const base = process.env.LOCAL_SD_URL;
|
||||
if (!base || !/^https?:\/\//i.test(base)) {
|
||||
throw new AiError("LOCAL_SD_URL is not a valid http(s) URL.", 500);
|
||||
}
|
||||
const url = `${base.replace(/\/$/, "")}/sdapi/v1/img2img`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
init_images: [imageBase64],
|
||||
prompt: instruction,
|
||||
denoising_strength: Number(process.env.LOCAL_SD_DENOISE ?? 0.55),
|
||||
steps: Number(process.env.LOCAL_SD_STEPS ?? 25),
|
||||
cfg_scale: 7,
|
||||
sampler_name: process.env.LOCAL_SD_SAMPLER || "Euler a",
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
throw new AiError("Could not reach your local Stable Diffusion server (LOCAL_SD_URL).", 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new AiError(`Local SD server error (${res.status}).`, 502);
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as { images?: string[] } | null;
|
||||
const out = data?.images?.[0];
|
||||
if (!out) throw new AiError("Local SD server did not return an image.", 502);
|
||||
// A1111 returns raw base64 PNG (no data: prefix).
|
||||
return { imageBase64: out.includes(",") ? out.split(",")[1]! : out, mimeType: "image/png" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: Hugging Face Inference API — instruction image editing.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editHuggingFace(instruction: string, imageBase64: string): Promise<EditResult> {
|
||||
const token = process.env.HF_API_TOKEN;
|
||||
if (!token) throw new AiError("HF_API_TOKEN is not set.", 500);
|
||||
const model = process.env.HF_IMAGE_MODEL || "timbrooks/instruct-pix2pix";
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
// Wait for the model to warm up instead of a fast 503.
|
||||
"x-wait-for-model": "true",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputs: imageBase64,
|
||||
parameters: { prompt: instruction, guidance_scale: 7, image_guidance_scale: 1.5 },
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
throw new AiError("Could not reach the Hugging Face Inference API.", 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Truncated on purpose — never surface a full upstream body.
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 160);
|
||||
if (res.status === 503) throw new AiError("The model is loading — try again in ~20s.", 503);
|
||||
throw new AiError(`Hugging Face error (${res.status}). ${detail}`, 502);
|
||||
}
|
||||
// Success returns raw image bytes.
|
||||
const outMime = res.headers.get("content-type") || "image/png";
|
||||
if (outMime.startsWith("application/json")) {
|
||||
const j = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new AiError(
|
||||
j?.error ? `Hugging Face: ${j.error}` : "Hugging Face returned no image.",
|
||||
502,
|
||||
);
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
return { imageBase64: Buffer.from(buf).toString("base64"), mimeType: outMime };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend: Google Gemini image model (needs a billed key for image output).
|
||||
// ---------------------------------------------------------------------------
|
||||
async function editGemini(
|
||||
instruction: string,
|
||||
imageBase64: string,
|
||||
safeMime: string,
|
||||
): Promise<EditResult> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) throw new AiError("GEMINI_API_KEY is not set.", 500);
|
||||
const model = process.env.GEMINI_IMAGE_MODEL || "gemini-2.5-flash-image";
|
||||
const prompt = `Edit this image as follows: ${instruction}. Preserve realism unless explicitly asked otherwise.`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-goog-api-key": apiKey },
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ inlineData: { mimeType: safeMime, data: imageBase64 } },
|
||||
{ text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: { responseModalities: ["IMAGE"] },
|
||||
}),
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
throw new AiError("Could not reach the AI service.", 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Truncated on purpose — never surface a full upstream body.
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 160);
|
||||
throw new AiError(`AI service error (${res.status}). ${detail}`, 502);
|
||||
}
|
||||
const data = (await res.json().catch(() => null)) as GeminiResponse | null;
|
||||
const parts = data?.candidates?.[0]?.content?.parts ?? [];
|
||||
const imgPart = parts.find((p) => p.inlineData?.data || p.inline_data?.data);
|
||||
const out = imgPart?.inlineData?.data ?? imgPart?.inline_data?.data;
|
||||
if (!out)
|
||||
throw new AiError(
|
||||
"The model did not return an image (the free Gemini tier has no image output — use LOCAL_SD_URL or HF_API_TOKEN instead).",
|
||||
502,
|
||||
);
|
||||
const outMime = imgPart?.inlineData?.mimeType ?? imgPart?.inline_data?.mime_type ?? "image/png";
|
||||
return { imageBase64: out, mimeType: outMime };
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
text?: string;
|
||||
inlineData?: { mimeType?: string; data?: string };
|
||||
inline_data?: { mime_type?: string; data?: string };
|
||||
}
|
||||
interface GeminiResponse {
|
||||
candidates?: Array<{ content?: { parts?: GeminiPart[] } }>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import { rpTilt } from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60; // cold-start tilt worker can take a while
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Camera-tilt estimation proxy. Accepts a base64 image and returns
|
||||
* {rollDegrees, pitchDegrees, fovDegrees} from the RunPod tilt endpoint
|
||||
* (RUNPOD_TILT_URL) so the editor can auto-straighten.
|
||||
*
|
||||
* POST { image } -> { rollDegrees, pitchDegrees, fovDegrees }
|
||||
* Auth: session-gated. Rate limit: 30/min.
|
||||
*/
|
||||
|
||||
const MAX_BASE64 = 4_000_000; // ~3 MB decoded
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "tilt");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
let body: { image?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const image = typeof body.image === "string" ? body.image : "";
|
||||
if (!image) return NextResponse.json({ error: "Missing image." }, { status: 400 });
|
||||
if (image.length > MAX_BASE64) {
|
||||
return NextResponse.json({ error: "Image too large." }, { status: 413 });
|
||||
}
|
||||
|
||||
try {
|
||||
const tilt = await rpTilt(image);
|
||||
return NextResponse.json(tilt);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Tilt estimate failed.";
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { RunpodError } from "@/lib/server/runpod/client";
|
||||
import { rpTranscribe } from "@/lib/server/runpod/endpoints";
|
||||
|
||||
import { guard } from "../_guard";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60; // cold-start STT worker can take a while
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Speech-to-text proxy for voice annotations. Accepts base64 WAV (16 kHz mono
|
||||
* PCM16, produced in-browser) and returns the transcript. Calls the RunPod
|
||||
* voice-to-text endpoint (RUNPOD_STT_URL) — the key stays server-side.
|
||||
*
|
||||
* POST { audio, language? } -> { transcript, segments? }
|
||||
* Auth: session-gated. Rate limit: 20/min.
|
||||
*/
|
||||
|
||||
const MAX_BASE64 = 8_000_000; // ~6 MB decoded WAV — stays under serverless body limits
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await guard(req, "transcribe");
|
||||
if (!gate.ok) return gate.response;
|
||||
|
||||
let body: { audio?: unknown; language?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const audio = typeof body.audio === "string" ? body.audio : "";
|
||||
const language = typeof body.language === "string" ? body.language : undefined;
|
||||
if (!audio) return NextResponse.json({ error: "Missing audio." }, { status: 400 });
|
||||
if (audio.length > MAX_BASE64) {
|
||||
return NextResponse.json({ error: "Audio too long — keep it under ~30s." }, { status: 413 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { transcript, segments } = await rpTranscribe(audio, { language, punctuation: true });
|
||||
return NextResponse.json({ transcript, segments });
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e instanceof RunpodError ? e.message : e instanceof Error ? e.message : "Transcription failed.";
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -1190,6 +1190,326 @@
|
||||
.dash-root .proj-detail-row span { color: var(--muted); }
|
||||
.dash-root .proj-detail-row b { color: var(--text); font-weight: 700; }
|
||||
|
||||
/* ========================================================== */
|
||||
/* Leads — pipeline board + rich detail popup */
|
||||
/* ========================================================== */
|
||||
|
||||
/* ---- stat strip ---- */
|
||||
.dash-root .leads-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||
.dash-root .leads-stat { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi); position: relative; overflow: hidden; }
|
||||
.dash-root .leads-stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--orange); }
|
||||
.dash-root .leads-stat.tone-blue::before { background: var(--blue); }
|
||||
.dash-root .leads-stat.tone-purple::before { background: var(--purple); }
|
||||
.dash-root .leads-stat.tone-green::before { background: var(--green); }
|
||||
.dash-root .leads-stat.tone-orange::before { background: var(--orange); }
|
||||
.dash-root .leads-stat-ic { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); flex: 0 0 auto; }
|
||||
.dash-root .leads-stat.tone-blue .leads-stat-ic { background: color-mix(in srgb, var(--blue) 16%, transparent); color: #6f9bff; }
|
||||
.dash-root .leads-stat.tone-purple .leads-stat-ic { background: color-mix(in srgb, var(--purple) 16%, transparent); color: #b07bf2; }
|
||||
.dash-root .leads-stat.tone-green .leads-stat-ic { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
|
||||
.dash-root .leads-stat-val { font-size: 22px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
|
||||
.dash-root .leads-stat-lbl { font-size: 11.5px; color: var(--muted); font-weight: 600; margin-top: 4px; }
|
||||
|
||||
/* ---- toolbar ---- */
|
||||
.dash-root .leads-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.dash-root .leads-search { display: flex; align-items: center; gap: 9px; flex: 1 1 260px; min-width: 220px; height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); }
|
||||
.dash-root .leads-search:focus-within { border-color: color-mix(in srgb, var(--orange) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 14%, transparent); }
|
||||
.dash-root .leads-search input { flex: 1; border: 0; background: none; outline: none; color: var(--text); font-family: inherit; font-size: 13.5px; }
|
||||
.dash-root .leads-search input::placeholder { color: var(--muted); }
|
||||
.dash-root .leads-search-x { border: 0; background: none; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 2px; border-radius: 6px; }
|
||||
.dash-root .leads-search-x:hover { color: var(--text); background: var(--panel-3); }
|
||||
.dash-root .leads-tabs { display: inline-flex; gap: 3px; padding: 4px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); }
|
||||
.dash-root .leads-tab { border: 0; background: none; color: var(--muted); font-family: inherit; font-size: 12.5px; font-weight: 600; padding: 7px 13px; border-radius: 9px; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .leads-tab:hover { color: var(--text-2); }
|
||||
.dash-root .leads-tab.active { background: var(--orange); color: #1a1205; box-shadow: 0 4px 12px -4px color-mix(in srgb, var(--orange) 60%, transparent); }
|
||||
|
||||
/* ---- board ---- */
|
||||
.dash-root .leads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||||
.dash-root .lead-card { text-align: left; width: 100%; cursor: pointer; display: flex; flex-direction: column; gap: 11px; padding: 16px 17px; border-radius: 18px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi), 0 1px 2px rgba(0,0,0,0.18); font-family: inherit; color: var(--text); transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; }
|
||||
.dash-root .lead-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--orange) 40%, var(--border)); box-shadow: var(--shadow), 0 14px 34px -20px color-mix(in srgb, var(--orange) 50%, transparent); }
|
||||
.dash-root .lead-card-top { display: flex; align-items: center; gap: 12px; }
|
||||
.dash-root .lead-ava { position: relative; border-radius: 50%; padding: 3px; display: inline-flex; }
|
||||
.dash-root .lead-ava.prio-high { box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 70%, transparent); }
|
||||
.dash-root .lead-ava.prio-medium { box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 70%, transparent); }
|
||||
.dash-root .lead-ava.prio-low { box-shadow: 0 0 0 2px var(--border-2); }
|
||||
.dash-root .lead-card-id { flex: 1; min-width: 0; }
|
||||
.dash-root .lead-card-name { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.dash-root .lead-card-sub { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
||||
.dash-root .lead-card-sub svg { color: var(--orange); }
|
||||
.dash-root .lead-code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--text-2); }
|
||||
.dash-root .lead-card-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-2); }
|
||||
.dash-root .lead-card-row svg { color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .lead-card-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .lead-card-foot { display: flex; align-items: center; gap: 8px; margin-top: 3px; padding-top: 11px; border-top: 1px solid var(--border); }
|
||||
.dash-root .lead-source { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--muted); }
|
||||
.dash-root .lead-card-spacer { flex: 1; }
|
||||
.dash-root .lead-rep { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
|
||||
.dash-root .lead-updated { font-size: 10.5px; color: var(--faint, var(--muted)); }
|
||||
|
||||
.dash-root .leads-empty { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px; padding: 48px 20px; color: var(--muted); }
|
||||
.dash-root .leads-empty svg { color: var(--muted); margin-bottom: 6px; }
|
||||
.dash-root .leads-empty h3 { font-size: 16px; color: var(--text); }
|
||||
|
||||
/* ---- detail popup ---- */
|
||||
.dash-root .lead-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .ld-identity { display: flex; align-items: center; gap: 14px; }
|
||||
.dash-root .ld-identity-name { font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.dash-root .ld-identity-pills { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.dash-root .ld-storm { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--orange) 30%, var(--border)); background: color-mix(in srgb, var(--orange) 9%, transparent); }
|
||||
.dash-root .ld-storm-ic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); flex: 0 0 auto; }
|
||||
.dash-root .ld-storm-zone { font-size: 13.5px; font-weight: 700; }
|
||||
.dash-root .ld-storm-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
.dash-root .ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.dash-root .ld-section { border: 1px solid var(--border); border-radius: 14px; background: var(--panel-2); padding: 14px 15px; }
|
||||
.dash-root .ld-section.wide { grid-column: 1 / -1; }
|
||||
.dash-root .ld-section-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--orange); margin-bottom: 12px; }
|
||||
.dash-root .ld-section-body { display: flex; flex-direction: column; gap: 3px; }
|
||||
.dash-root .ld-dl { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
||||
.dash-root .ld-dl:last-child { border-bottom: 0; }
|
||||
.dash-root .ld-dl-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .ld-dl-v { font-size: 12.5px; color: var(--text); font-weight: 600; text-align: right; }
|
||||
.dash-root .ld-assign { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; }
|
||||
.dash-root .ld-sublabel { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--faint, var(--muted)); font-weight: 700; margin: 8px 0 6px; }
|
||||
.dash-root .ld-sublabel:first-child { margin-top: 0; }
|
||||
.dash-root .ld-contact-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 12.5px; color: var(--text); }
|
||||
.dash-root .ld-contact-row svg { color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .ld-contact-val { font-weight: 600; }
|
||||
.dash-root .ld-contact-tag { font-size: 10.5px; color: var(--muted); padding: 2px 7px; border-radius: 99px; background: var(--panel-3); }
|
||||
.dash-root .ld-notes p { font-size: 12.5px; color: var(--text-2); line-height: 1.5; margin-top: 2px; }
|
||||
|
||||
/* ---- New Lead form ---- */
|
||||
.dash-root .nl-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .nl-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||
.dash-root .nl-grid .ds-field { margin-bottom: 0; }
|
||||
.dash-root .nl-grid > .ds-field, .dash-root .nl-grid > .nl-full { margin-bottom: 14px; }
|
||||
.dash-root .nl-full { grid-column: 1 / -1; }
|
||||
.dash-root .nl-full .ds-field { margin-bottom: 0; }
|
||||
.dash-root .nl-prio { display: flex; gap: 8px; }
|
||||
.dash-root .nl-prio-btn { flex: 1; height: 42px; border-radius: 11px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-prio-btn:hover { color: var(--text-2); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.active.low { background: color-mix(in srgb, var(--muted) 22%, transparent); color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.active.medium { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.active.high { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.urg.active.standard { background: color-mix(in srgb, var(--muted) 20%, transparent); color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.urg.active.high { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.urg.active.emergency { background: color-mix(in srgb, var(--red) 20%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 60%, transparent); }
|
||||
|
||||
/* multi-value rows (phones / emails) */
|
||||
.dash-root .nl-multirow { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.dash-root .nl-multirow .ds-input { flex: 1; }
|
||||
.dash-root .nl-typesel { flex: 0 0 108px; width: 108px; }
|
||||
.dash-root .nl-rowx { flex: 0 0 auto; width: 42px; border-radius: 11px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; display: grid; place-items: center; transition: 0.14s; }
|
||||
.dash-root .nl-rowx:hover { color: var(--red); border-color: color-mix(in srgb, var(--red) 50%, transparent); }
|
||||
.dash-root .nl-add { display: inline-flex; align-items: center; gap: 6px; margin-top: 2px; padding: 8px 13px; border-radius: 10px; border: 1px dashed var(--border-2); background: none; color: var(--orange); font-family: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-add:hover { background: color-mix(in srgb, var(--orange) 10%, transparent); border-color: color-mix(in srgb, var(--orange) 45%, transparent); }
|
||||
.dash-root .nl-empty { font-size: 12.5px; color: var(--faint, var(--muted)); padding: 8px 0 10px; }
|
||||
|
||||
/* site photos dropzone */
|
||||
.dash-root .nl-photos { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 22px; border-radius: 14px; border: 1.5px dashed var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-photos:hover { border-color: color-mix(in srgb, var(--orange) 50%, transparent); color: var(--orange); background: color-mix(in srgb, var(--orange) 7%, transparent); }
|
||||
.dash-root .nl-photos-t { font-size: 13px; font-weight: 700; color: var(--text-2); }
|
||||
.dash-root .nl-photos:hover .nl-photos-t { color: var(--orange); }
|
||||
.dash-root .nl-photos-s { font-size: 11px; }
|
||||
.dash-root .nl-photo-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.dash-root .nl-photo-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 99px; background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
|
||||
.dash-root .ds-select.is-placeholder { color: var(--faint, var(--muted)); }
|
||||
|
||||
/* Canvasser search (Door Knock source) */
|
||||
.dash-root .nl-canvasser { position: relative; }
|
||||
.dash-root .nl-canvasser-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30; max-height: 220px; overflow-y: auto; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel); box-shadow: 0 12px 32px rgba(0,0,0,0.28); padding: 5px; }
|
||||
.dash-root .nl-canvasser-opt { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: none; border-radius: 9px; background: transparent; color: var(--text); font-family: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: 0.12s; }
|
||||
.dash-root .nl-canvasser-opt:hover { background: var(--panel-2); }
|
||||
.dash-root .nl-canvasser-name { font-weight: 700; }
|
||||
.dash-root .nl-canvasser-email { color: var(--muted); font-size: 12px; }
|
||||
.dash-root .nl-canvasser-opt .nl-canvasser-email { margin-left: auto; }
|
||||
.dash-root .nl-canvasser-empty { padding: 10px; color: var(--muted); font-size: 12.5px; text-align: center; }
|
||||
.dash-root .nl-canvasser-chip { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel-2); }
|
||||
.dash-root .nl-canvasser-chip .nl-canvasser-email { margin-left: 2px; }
|
||||
.dash-root .nl-canvasser-clear { margin-left: auto; display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; transition: 0.12s; }
|
||||
.dash-root .nl-canvasser-clear:hover { background: color-mix(in srgb, var(--red) 15%, transparent); color: var(--red); }
|
||||
|
||||
/* ========================================================== */
|
||||
/* Lead Verification — stat tiles + filters + table */
|
||||
/* ========================================================== */
|
||||
.dash-root .lv-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||
.dash-root .lv-stat { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi); cursor: pointer; text-align: left; font-family: inherit; transition: 0.15s; position: relative; }
|
||||
.dash-root .lv-stat:hover { border-color: var(--border-2); transform: translateY(-2px); }
|
||||
.dash-root .lv-stat.active { border-color: color-mix(in srgb, var(--accent, var(--orange)) 60%, transparent); box-shadow: var(--card-hi), 0 0 0 1px color-mix(in srgb, var(--accent, var(--orange)) 40%, transparent); }
|
||||
.dash-root .lv-stat-ic { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; margin-bottom: 6px; }
|
||||
.dash-root .lv-stat-val { font-size: 22px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
|
||||
.dash-root .lv-stat-lbl { font-size: 11.5px; color: var(--muted); font-weight: 600; }
|
||||
.dash-root .lv-stat.tone-green { --accent: var(--green); } .dash-root .lv-stat.tone-green .lv-stat-ic { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
|
||||
.dash-root .lv-stat.tone-orange { --accent: var(--orange); } .dash-root .lv-stat.tone-orange .lv-stat-ic { background: color-mix(in srgb, var(--orange) 16%, transparent); color: var(--orange); }
|
||||
.dash-root .lv-stat.tone-blue { --accent: var(--blue); } .dash-root .lv-stat.tone-blue .lv-stat-ic { background: color-mix(in srgb, var(--blue) 18%, transparent); color: #6f9bff; }
|
||||
.dash-root .lv-stat.tone-purple { --accent: var(--purple); } .dash-root .lv-stat.tone-purple .lv-stat-ic { background: color-mix(in srgb, var(--purple) 18%, transparent); color: #b07bf2; }
|
||||
.dash-root .lv-stat.tone-red { --accent: var(--red); } .dash-root .lv-stat.tone-red .lv-stat-ic { background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red); }
|
||||
|
||||
.dash-root .lv-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.dash-root .lv-search { display: flex; align-items: center; gap: 9px; flex: 1 1 240px; min-width: 200px; height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); }
|
||||
.dash-root .lv-search:focus-within { border-color: color-mix(in srgb, var(--orange) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 14%, transparent); }
|
||||
.dash-root .lv-search input { flex: 1; border: 0; background: none; outline: none; color: var(--text); font-family: inherit; font-size: 13.5px; }
|
||||
.dash-root .lv-search input::placeholder { color: var(--muted); }
|
||||
.dash-root .lv-search-x { border: 0; background: none; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 2px; border-radius: 6px; }
|
||||
.dash-root .lv-search-x:hover { color: var(--text); background: var(--panel-3); }
|
||||
.dash-root .lv-filter { height: 42px; flex: 0 0 auto; width: auto; min-width: 150px; }
|
||||
|
||||
.dash-root .lv-tablewrap { border: 1px solid var(--border); border-radius: 18px; background: var(--card-grad); box-shadow: var(--card-hi); overflow-x: auto; }
|
||||
.dash-root .lv-table { width: 100%; border-collapse: collapse; min-width: 940px; }
|
||||
.dash-root .lv-table thead th { text-align: left; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 14px 16px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.dash-root .lv-table tbody td { padding: 13px 16px; border-bottom: 1px solid var(--border); font-size: 12.5px; vertical-align: middle; }
|
||||
.dash-root .lv-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.dash-root .lv-table tbody tr { transition: background 0.12s; }
|
||||
.dash-root .lv-table tbody tr:hover { background: color-mix(in srgb, var(--orange) 5%, transparent); }
|
||||
.dash-root .lv-id { font-family: ui-monospace, monospace; font-size: 12px; font-weight: 700; color: var(--text); white-space: nowrap; }
|
||||
.dash-root .lv-id-date { font-size: 10.5px; color: var(--faint, var(--muted)); margin-top: 2px; }
|
||||
.dash-root .lv-cust { display: flex; align-items: center; gap: 10px; min-width: 210px; }
|
||||
.dash-root .lv-cust-name { font-weight: 700; font-size: 13px; color: var(--text); }
|
||||
.dash-root .lv-cust-addr { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.dash-root .lv-phone { color: var(--text-2); white-space: nowrap; }
|
||||
.dash-root .lv-source { display: inline-block; font-size: 11.5px; font-weight: 600; color: var(--text-2); padding: 4px 10px; border-radius: 99px; background: var(--panel-3); white-space: nowrap; }
|
||||
.dash-root .lv-assignee { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.dash-root .lv-assignee span { font-weight: 600; color: var(--text-2); font-size: 12px; }
|
||||
.dash-root .lv-unassigned { font-size: 11.5px; color: var(--faint, var(--muted)); }
|
||||
.dash-root .lv-verif { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; white-space: nowrap; color: var(--muted); }
|
||||
.dash-root .lv-verif.v-verified { color: var(--green); }
|
||||
.dash-root .lv-verif.v-in_progress { color: var(--orange); }
|
||||
.dash-root .lv-verif.v-assigned { color: #6f9bff; }
|
||||
.dash-root .lv-verif.v-pending { color: #b07bf2; }
|
||||
.dash-root .lv-verif.v-unverified { color: var(--red); }
|
||||
.dash-root .lv-created { color: var(--muted); white-space: nowrap; }
|
||||
.dash-root .lv-rowacts { display: flex; gap: 6px; }
|
||||
.dash-root .lv-act { width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; display: grid; place-items: center; transition: 0.14s; }
|
||||
.dash-root .lv-act:hover { color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .lv-act.primary:hover { color: var(--green); border-color: color-mix(in srgb, var(--green) 50%, transparent); background: color-mix(in srgb, var(--green) 10%, transparent); }
|
||||
.dash-root .lv-empty { text-align: center; color: var(--muted); padding: 40px 16px; font-size: 13px; }
|
||||
.dash-root .lv-count { font-size: 11.5px; color: var(--muted); margin-top: 12px; text-align: right; }
|
||||
|
||||
/* ---- row actions dropdown (portalled to body) ---- */
|
||||
.lv-menu-scrim { position: fixed; inset: 0; z-index: 90; }
|
||||
.lv-menu { position: fixed; z-index: 91; width: 188px; padding: 6px; border-radius: 12px; border: 1px solid var(--border-2, rgba(255,255,255,0.12)); background: var(--panel, #0e0e13); box-shadow: 0 18px 44px -18px rgba(0,0,0,0.7); animation: ds-rise 0.13s ease; }
|
||||
.lv-menu-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px; border: 0; border-radius: 9px; background: none; color: var(--text-2, #eaeaea); font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; }
|
||||
.lv-menu-item:hover { background: var(--panel-3, #1b1b22); color: var(--text, #fff); }
|
||||
.lv-menu-item svg { color: var(--muted, #8c8c8c); flex: 0 0 auto; }
|
||||
.lv-menu-item:hover svg { color: var(--orange, #fda913); }
|
||||
|
||||
/* ---- verification detail popup ---- */
|
||||
.dash-root .lv-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .lv-d-identity { display: flex; align-items: center; gap: 14px; }
|
||||
.dash-root .lv-d-name { font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.dash-root .lv-d-pills { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.dash-root .lv-d-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.dash-root .lv-d-section, .dash-root .lv-d-notes, .dash-root .lv-d-activity { border: 1px solid var(--border); border-radius: 14px; background: var(--panel-2); padding: 14px 15px; }
|
||||
.dash-root .lv-d-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--orange); margin-bottom: 12px; }
|
||||
.dash-root .lv-d-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
||||
.dash-root .lv-d-row:last-child { border-bottom: 0; }
|
||||
.dash-root .lv-d-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .lv-d-v { font-size: 12.5px; color: var(--text); font-weight: 600; text-align: right; }
|
||||
.dash-root .lv-d-notes p { font-size: 12.5px; color: var(--text-2); line-height: 1.55; margin-top: 2px; }
|
||||
|
||||
.dash-root .lv-timeline { list-style: none; margin: 0; padding: 0; }
|
||||
.dash-root .lv-tl-item { position: relative; display: flex; gap: 12px; padding: 0 0 16px 4px; }
|
||||
.dash-root .lv-tl-item::before { content: ""; position: absolute; left: 8px; top: 14px; bottom: -2px; width: 1.5px; background: var(--border-2); }
|
||||
.dash-root .lv-tl-item:last-child { padding-bottom: 0; }
|
||||
.dash-root .lv-tl-item:last-child::before { display: none; }
|
||||
.dash-root .lv-tl-dot { position: relative; z-index: 1; flex: 0 0 auto; width: 10px; height: 10px; margin-top: 4px; border-radius: 50%; background: var(--orange); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 20%, transparent); }
|
||||
.dash-root .lv-tl-text { font-size: 12.5px; color: var(--text); font-weight: 600; }
|
||||
.dash-root .lv-tl-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dash-root .lv-d-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dash-root .leads-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.dash-root .ld-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .ld-assign { grid-template-columns: 1fr; }
|
||||
.dash-root .lv-stats { grid-template-columns: repeat(3, 1fr); }
|
||||
.dash-root .lv-filter { flex: 1 1 45%; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.dash-root .leads-stats { grid-template-columns: 1fr; }
|
||||
.dash-root .leads-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .nl-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .lv-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Smart Gallery — the embedded @photo-gallery/sdk surface.
|
||||
The SDK is themed entirely through the token map in lib/gallery-api.ts
|
||||
(--apg-* -> this file's own vars), so the rules below only handle the
|
||||
host chrome: sizing, the demo banner, and the load/error placeholders.
|
||||
========================================================================= */
|
||||
|
||||
/* The gallery is the one view that wants the whole viewport: it has its own sidebar, toolbar and
|
||||
scrollers, so any height we leave on the table is wasted chrome. The old big PageHead cost ~90px;
|
||||
a slim header (~40px) + compact banner + tight gaps hand almost all of that back to the shell.
|
||||
96px = the dashboard's top padding + the slim header row; `.gal-shell` (flex:1; min-height:0)
|
||||
consumes whatever is left after the header and the optional demo banner. */
|
||||
.dash-root .gal { display: flex; flex-direction: column; gap: 10px; height: calc(100vh - 96px); min-height: 700px; }
|
||||
|
||||
/* Slim inline header — replaces the tall PageHead. One row, ~40px, so the shell keeps the height. */
|
||||
.dash-root .gal-head { display: flex; align-items: center; gap: 10px; min-height: 36px; flex: 0 0 auto; }
|
||||
.dash-root .gal-head-ic { width: 28px; height: 28px; border-radius: 9px; display: grid; place-items: center; color: #fff; background: var(--grad-brand); box-shadow: var(--glow-orange); flex: 0 0 auto; }
|
||||
.dash-root .gal-head-title { font-size: 16px; font-weight: 700; line-height: 1.1; margin: 0; }
|
||||
.dash-root .gal-head-sub { color: var(--muted); font-size: 12.5px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
@media (max-width: 720px) { .dash-root .gal-head-sub { display: none; } }
|
||||
|
||||
/* `.view` sets `z-index: 1`, which makes it a stacking context and traps the SDK's
|
||||
full-screen overlays (lightbox z1000, editors z1050, camera z1080, modals z1100)
|
||||
underneath the topbar's `z-index: 20`. Opting this one view out of the stacking
|
||||
context lets those overlays cover the whole dashboard, as they must. The view still
|
||||
paints above the ambient `.dash-content::before` glow because it follows it in the DOM. */
|
||||
.dash-root .view.gal { z-index: auto; }
|
||||
|
||||
/* Compact single-line demo banner (~34px). Truncates rather than wrapping so it never steals a
|
||||
second row of height from the shell. */
|
||||
.dash-root .gal-banner { display: flex; align-items: center; gap: 8px; min-height: 34px; padding: 6px 12px; border-radius: 11px; border: 1px solid var(--border); background: color-mix(in srgb, var(--orange) 9%, var(--panel-2)); color: var(--text-2); font-size: 12px; font-weight: 500; flex: 0 0 auto; white-space: nowrap; overflow: hidden; }
|
||||
.dash-root .gal-banner span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.dash-root .gal-banner svg { color: var(--orange); flex: 0 0 auto; }
|
||||
|
||||
/* The gallery's own viewport. `overflow: hidden` keeps the SDK's internal scrollers
|
||||
in charge; its full-screen overlays (lightbox/editor/camera) are position:fixed
|
||||
and deliberately escape this box to cover the whole dashboard. */
|
||||
.dash-root .gal-shell { flex: 1; min-height: 0; position: relative; border-radius: 18px; border: 1px solid var(--border); background: var(--panel-2); overflow: hidden; box-shadow: var(--card-hi), 0 1px 2px rgba(0, 0, 0, 0.18); }
|
||||
.dash-root[data-theme="dark"] .gal-shell { border: 0.5px solid #452b1a; border-radius: 20px; }
|
||||
|
||||
/* The SDK's embedded root fills this box. (--apg-overlay-top is set from the
|
||||
component's `style` prop — the SDK writes an inline default that a stylesheet
|
||||
rule could not override.) */
|
||||
.dash-root .gal-shell .apg { height: 100%; }
|
||||
|
||||
/* ---- Fullscreen (the SDK puts `.apg--fullscreen` on its root: position:fixed; inset:0) ----
|
||||
A position:fixed box is only clipped by an ancestor that is its CONTAINING BLOCK, which
|
||||
`overflow`/`border-radius`/`box-shadow` alone never create — only transform / filter /
|
||||
perspective / backdrop-filter / will-change / contain do. Nothing on the path
|
||||
(.dash-content > .view.gal > .gal-shell) uses any of those: `.view`'s `ds-fade` animates
|
||||
opacity only, and `.view.gal` already drops the `z-index: 1` stacking context. So the
|
||||
fullscreen root does escape today — these rules make that survive an edit above. */
|
||||
|
||||
/* `.dash-root .gal-shell .apg` (0,3,0) would otherwise out-specify the SDK's own sizing; with
|
||||
inset:0 driving the box, height must get out of the way. */
|
||||
.dash-root .gal-shell .apg.apg--fullscreen {
|
||||
height: auto;
|
||||
/* Above the topbar (z-index: 20) and the sidebar, below the SDK's own overlays (1000+). */
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
/* Belt and braces: if a future rule ever DOES make `.gal-shell` a containing block, an
|
||||
`overflow: hidden` on it would crop the fullscreen root to the embedded box. Drop the clip
|
||||
(and the rounded corner it exists to enforce) for exactly as long as fullscreen is on. */
|
||||
.dash-root .gal-shell:has(.apg--fullscreen) { overflow: visible; }
|
||||
|
||||
.dash-root .gal-placeholder { height: 100%; min-height: 320px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; padding: 28px; }
|
||||
.dash-root .gal-placeholder-ic { width: 66px; height: 66px; border-radius: 20px; display: grid; place-items: center; color: #fff; background: var(--grad-brand); box-shadow: var(--glow-orange); }
|
||||
.dash-root .gal-placeholder p { color: var(--muted); font-size: 13px; max-width: 420px; }
|
||||
.dash-root .gal-placeholder h3 { font-size: 16px; font-weight: 700; }
|
||||
.dash-root .gal-placeholder-error .gal-placeholder-ic { background: color-mix(in srgb, var(--red) 88%, #000); box-shadow: 0 10px 28px -12px color-mix(in srgb, var(--red) 60%, transparent); }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
/* Narrower chrome: a little less top offset, and a smaller floor so short viewports still work. */
|
||||
.dash-root .gal { height: calc(100vh - 84px); min-height: 560px; }
|
||||
}
|
||||
|
||||
/* ---- Org Settings → Integrations ---- */
|
||||
.dash-root .settings-section { margin-top: 8px; }
|
||||
.dash-root .settings-section-title { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; }
|
||||
@@ -1210,3 +1530,25 @@
|
||||
.dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; }
|
||||
.dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; }
|
||||
.dash-root .settings-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
||||
|
||||
/* ---- Global conversation search (topbar) ---- */
|
||||
.dash-root .gs-wrap { position: relative; }
|
||||
.dash-root .gs-field { display: flex; align-items: center; gap: 8px; height: 40px; width: 300px; max-width: 42vw; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); }
|
||||
.dash-root .gs-field:focus-within { border-color: var(--orange); }
|
||||
.dash-root .gs-input { flex: 1 1 auto; border: 0; background: none; outline: none; color: var(--text); font-size: 13.5px; }
|
||||
.dash-root .gs-input::placeholder { color: var(--muted); }
|
||||
.dash-root .gs-pop { position: absolute; top: calc(100% + 6px); right: 0; width: 420px; max-width: 90vw; max-height: 420px; overflow-y: auto; padding: 6px; border-radius: 14px; border: 1px solid var(--border); background: var(--panel); box-shadow: 0 24px 60px -20px rgba(0,0,0,0.55); z-index: 60; }
|
||||
.dash-root .gs-empty { padding: 14px; font-size: 13px; color: var(--muted); text-align: center; }
|
||||
.dash-root .gs-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px; border: 0; background: none; border-radius: 10px; cursor: pointer; text-align: left; color: var(--text); }
|
||||
.dash-root .gs-row:hover { background: var(--panel-2); }
|
||||
.dash-root .gs-ic { flex: 0 0 auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
|
||||
.dash-root .gs-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.dash-root .gs-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .gs-snippet { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .gs-snippet em { color: var(--orange); font-style: normal; font-weight: 600; }
|
||||
.dash-root .gs-surface { flex: 0 0 auto; font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
|
||||
@media (max-width: 720px) { .dash-root .gs-field { width: 160px; } }
|
||||
|
||||
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
|
||||
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
|
||||
|
||||
@@ -19,17 +19,48 @@ import { MessengerSdk } from "./messenger-sdk";
|
||||
import { InboxSdk } from "./inbox-sdk";
|
||||
import { Settings } from "./settings";
|
||||
import { Projects } from "./projects";
|
||||
import { NotificationCenter } from "./notification-center";
|
||||
import { RealtimeProvider } from "@/lib/realtime";
|
||||
import { SmartGallery } from "./smart-gallery";
|
||||
import { Leads } from "./leads";
|
||||
import { Verify } from "./verify";
|
||||
import "../../app/dashboard/dashboard.css";
|
||||
|
||||
export function Dashboard() {
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const [active, setActive] = useState("dashboard");
|
||||
// Deep link from global search: which conversation to focus once we switch tabs.
|
||||
const [deepLink, setDeepLink] = useState<{ surface: "messenger" | "inbox"; threadId: string } | null>(null);
|
||||
|
||||
function navigateToConversation(surface: "messenger" | "inbox", threadId: string) {
|
||||
setActive(surface);
|
||||
setDeepLink({ surface, threadId });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Sync the persisted theme from localStorage (an external system) on mount.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {}
|
||||
}, []);
|
||||
|
||||
// Deep-link from a clicked push notification. Two paths from the service worker:
|
||||
// - a tab was already open → it postMessages { type: 'notif-click', threadId } to focus here
|
||||
// - no tab was open → it opens /dashboard?thread=<id>, which we read once on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const t = new URLSearchParams(window.location.search).get("thread");
|
||||
if (t) {
|
||||
navigateToConversation("messenger", t);
|
||||
window.history.replaceState({}, "", window.location.pathname);
|
||||
}
|
||||
} catch {}
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.data?.type === "notif-click" && e.data.threadId) navigateToConversation("messenger", e.data.threadId);
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("message", onMessage);
|
||||
return () => navigator.serviceWorker.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
function toggle() {
|
||||
setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; });
|
||||
}
|
||||
@@ -40,24 +71,30 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="dash-root" data-theme={theme}>
|
||||
<RealtimeProvider>
|
||||
<Sidebar active={active} onSelect={setActive} />
|
||||
<div className="dash-main">
|
||||
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} />
|
||||
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} onNavigate={navigateToConversation} />
|
||||
<div className="dash-content">
|
||||
<ToastProvider>
|
||||
<NotificationCenter active={active} onNavigate={navigateToConversation} />
|
||||
{active === "profile" ? <Profile />
|
||||
: active === "support" ? <Support />
|
||||
: active === "rules" ? <Rules />
|
||||
: active === "ai" ? <AiAssistant />
|
||||
: active === "messenger" ? <MessengerSdk />
|
||||
: active === "inbox" ? <InboxSdk />
|
||||
: active === "messenger" ? <MessengerSdk focusThreadId={deepLink?.surface === "messenger" ? deepLink.threadId : null} />
|
||||
: active === "inbox" ? <InboxSdk focusThreadId={deepLink?.surface === "inbox" ? deepLink.threadId : null} />
|
||||
: active === "settings" ? <Settings />
|
||||
: active === "gallery" ? <SmartGallery theme={theme} />
|
||||
: active === "leads" ? <Leads />
|
||||
: active === "verify" ? <Verify />
|
||||
: active === "team" ? <TeamManagement />
|
||||
: active === "projects" ? <Projects />
|
||||
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</div>
|
||||
</RealtimeProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
// Global conversation search in the topbar: type → debounced crm.search → dropdown of hits; click a
|
||||
// hit to deep-link to exactly where it lives (mail → Inbox, chat → Messenger, on that thread).
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "./ui";
|
||||
import { useGlobalSearch, type SearchResult } from "@/lib/search-api";
|
||||
|
||||
/** Escape HTML but keep the engine's <em> highlight tags — so a match snippet can't inject markup. */
|
||||
function safeSnippet(s: string): string {
|
||||
const esc = s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
return esc.replace(/<em>/g, "<em>").replace(/<\/em>/g, "</em>");
|
||||
}
|
||||
|
||||
export function GlobalSearch({ onNavigate }: { onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
||||
const search = useGlobalSearch();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const term = q.trim();
|
||||
if (!term) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
const t = setTimeout(() => {
|
||||
search(term)
|
||||
.then((r) => { if (alive) setResults(r); })
|
||||
.catch(() => { if (alive) setResults([]); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
}, 220);
|
||||
return () => { alive = false; clearTimeout(t); };
|
||||
}, [q, search]);
|
||||
|
||||
useEffect(() => {
|
||||
function onDown(e: MouseEvent) {
|
||||
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, []);
|
||||
|
||||
function pick(r: SearchResult) {
|
||||
onNavigate(r.surface, r.threadId);
|
||||
setOpen(false);
|
||||
setQ("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gs-wrap" ref={wrapRef}>
|
||||
<div className="gs-field">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
className="gs-input"
|
||||
placeholder="Search conversations…"
|
||||
value={q}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
|
||||
aria-label="Search conversations"
|
||||
/>
|
||||
</div>
|
||||
{open && q.trim() ? (
|
||||
<div className="gs-pop">
|
||||
{loading && results.length === 0 ? <div className="gs-empty">Searching…</div> : null}
|
||||
{!loading && results.length === 0 ? <div className="gs-empty">No matches.</div> : null}
|
||||
{results.map((r) => (
|
||||
<button key={r.interactionId} type="button" className="gs-row" onClick={() => pick(r)}>
|
||||
<span className="gs-ic"><Icon name={r.surface === "inbox" ? "mail" : "send"} size={14} /></span>
|
||||
<span className="gs-main">
|
||||
<span className="gs-title">{r.title}</span>
|
||||
<span className="gs-snippet" dangerouslySetInnerHTML={{ __html: safeSnippet(r.snippet) }} />
|
||||
</span>
|
||||
<span className="gs-surface">{r.surface === "inbox" ? "Mail" : "Chat"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import type { DataDoor } from "@/lib/crm-messaging-adapter";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function InboxSdk() {
|
||||
export function InboxSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||||
return (
|
||||
<div className="view">
|
||||
{!SHELL && (
|
||||
@@ -23,26 +23,26 @@ export function InboxSdk() {
|
||||
Demo mode — running on the SDK's mock inbox adapter.
|
||||
</div>
|
||||
)}
|
||||
<div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox /> : <DemoInbox />}</div>
|
||||
<div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox focusThreadId={focusThreadId} /> : <DemoInbox focusThreadId={focusThreadId} />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoInbox() {
|
||||
function DemoInbox({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||
const adapter = useMemo<InboxAdapter>(() => new MockInboxAdapter(), []);
|
||||
return (
|
||||
<InboxProvider adapter={adapter}>
|
||||
<SdkInbox />
|
||||
<SdkInbox focusThreadId={focusThreadId} />
|
||||
</InboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveInbox() {
|
||||
function LiveInbox({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||
const { sdk } = useAppShell();
|
||||
const adapter = useMemo<InboxAdapter>(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]);
|
||||
return (
|
||||
<InboxProvider adapter={adapter}>
|
||||
<SdkInbox />
|
||||
<SdkInbox focusThreadId={focusThreadId} />
|
||||
</InboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// ============================================================
|
||||
// LynkedUp Pro — Leads mock data.
|
||||
// A storm-restoration roofing pipeline: door-knocked leads in
|
||||
// the Plano, TX hail zone. Each lead carries a rich detail
|
||||
// record (contact, property, job, insurance, assignment) that
|
||||
// powers the lead-detail popup. All client-side so the screen
|
||||
// is fully interactive without a backend.
|
||||
// ============================================================
|
||||
|
||||
export type LeadStatus = "new" | "contacted" | "appointed" | "closed";
|
||||
export type LeadPriority = "high" | "medium" | "low";
|
||||
|
||||
export type Phone = { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean };
|
||||
export type Email = { address: string; type?: string; primary?: boolean };
|
||||
|
||||
export type Lead = {
|
||||
id: string; // SAL-001
|
||||
initials: string;
|
||||
name: string;
|
||||
gradient: string;
|
||||
priority: LeadPriority;
|
||||
status: LeadStatus;
|
||||
tag: string; // "Storm Zone"
|
||||
updated: string; // "3d ago"
|
||||
setter: string; // compact chip name
|
||||
|
||||
// storm banner
|
||||
storm: { zone: string; date: string; detail: string };
|
||||
|
||||
// contact
|
||||
phones: Phone[];
|
||||
emails: Email[];
|
||||
|
||||
// property
|
||||
property: { address: string; city: string; state: string; zip: string; type: string };
|
||||
|
||||
// job details
|
||||
job: {
|
||||
source: string; leadType: string; workType: string; tradeType: string;
|
||||
urgency: string; canvasser: string; notes: string;
|
||||
};
|
||||
|
||||
// insurance
|
||||
insurance: {
|
||||
company: string; claimStatus: string; claimNumber: string;
|
||||
policyNumber: string; adjusterName: string; adjusterPhone: string;
|
||||
};
|
||||
|
||||
// assignment
|
||||
assignment: {
|
||||
assignedTo: string; priority: string; followUp: string;
|
||||
createdBy: string; createdAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
const G = {
|
||||
orange: "linear-gradient(135deg,#fda913,#fd6d13)",
|
||||
blue: "linear-gradient(135deg,#4f8cff,#2c5cff)",
|
||||
purple: "linear-gradient(135deg,#b07bf2,#7b53e0)",
|
||||
green: "linear-gradient(135deg,#33c98a,#1fa46c)",
|
||||
cyan: "linear-gradient(135deg,#34c9d6,#1f9aa4)",
|
||||
};
|
||||
|
||||
export const LEADS: Lead[] = [
|
||||
{
|
||||
id: "SAL-001", initials: "JM", name: "John Martinez", gradient: G.orange,
|
||||
priority: "high", status: "contacted", tag: "Storm Zone", updated: "3d ago", setter: "Cody",
|
||||
storm: { zone: "E Plano / Spring Creek Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(469) 500-1000", type: "Mobile", primary: true },
|
||||
{ number: "(214) 600-2000", type: "Home" },
|
||||
],
|
||||
emails: [{ address: "john.martinez@gmail.com", primary: true }],
|
||||
property: { address: "4821 Spring Creek Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Cody Tatum",
|
||||
notes: "Homeowner showed significant granule loss on south-facing slopes; agreed to inspection.",
|
||||
},
|
||||
insurance: {
|
||||
company: "State Farm", claimStatus: "Filed", claimNumber: "CLM-2026-1000",
|
||||
policyNumber: "POL-080000", adjusterName: "Marcus Powell", adjusterPhone: "(972) 700-3000",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Jesus Gonzales", priority: "High", followUp: "Jun 4, 2026",
|
||||
createdBy: "Cody Tatum", createdAt: "May 28, 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SAL-002", initials: "SK", name: "Sarah Kim", gradient: G.purple,
|
||||
priority: "high", status: "appointed", tag: "Storm Zone", updated: "2d ago", setter: "Shelby",
|
||||
storm: { zone: "E Plano / Custer Rd", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(972) 501-1037", type: "Mobile", primary: true },
|
||||
{ number: "(214) 601-2044", type: "Home" },
|
||||
],
|
||||
emails: [{ address: "sarah.kim@outlook.com", primary: true }],
|
||||
property: { address: "4905 Custer Rd", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Hannah Reyes",
|
||||
notes: "Visible mat exposure on rear elevation. Appointment set for adjuster meet.",
|
||||
},
|
||||
insurance: {
|
||||
company: "Allstate", claimStatus: "Approved", claimNumber: "CLM-2026-1037",
|
||||
policyNumber: "POL-081037", adjusterName: "Dana Whitfield", adjusterPhone: "(972) 700-3037",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Hannah Reyes", priority: "High", followUp: "Jun 6, 2026",
|
||||
createdBy: "Shelby Greer", createdAt: "May 29, 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SAL-003", initials: "RC", name: "Robert Chen", gradient: G.green,
|
||||
priority: "high", status: "closed", tag: "Storm Zone", updated: "2d ago", setter: "Dalton",
|
||||
storm: { zone: "E Plano / Independence Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(469) 502-1074", type: "Mobile", primary: true },
|
||||
],
|
||||
emails: [{ address: "robert.chen@gmail.com", primary: true }],
|
||||
property: { address: "5012 Independence Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Travis Boone",
|
||||
notes: "Full replacement approved and installed. Final invoice cleared.",
|
||||
},
|
||||
insurance: {
|
||||
company: "Farmers", claimStatus: "Paid", claimNumber: "CLM-2026-1074",
|
||||
policyNumber: "POL-081074", adjusterName: "Leah Ortiz", adjusterPhone: "(972) 700-3074",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Travis Boone", priority: "High", followUp: "—",
|
||||
createdBy: "Dalton Pruitt", createdAt: "May 20, 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SAL-004", initials: "MG", name: "Maria Garcia", gradient: G.cyan,
|
||||
priority: "high", status: "closed", tag: "Storm Zone", updated: "2d ago", setter: "Hannah",
|
||||
storm: { zone: "E Plano / Alma Dr", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(972) 503-1111", type: "Mobile", primary: true },
|
||||
],
|
||||
emails: [{ address: "maria.garcia@gmail.com", primary: true }],
|
||||
property: { address: "4720 Alma Dr", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Shelby Greer",
|
||||
notes: "Signed contract; build complete. Awaiting review request.",
|
||||
},
|
||||
insurance: {
|
||||
company: "USAA", claimStatus: "Paid", claimNumber: "CLM-2026-1111",
|
||||
policyNumber: "POL-081111", adjusterName: "Grant Mueller", adjusterPhone: "(972) 700-3111",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Shelby Greer", priority: "High", followUp: "—",
|
||||
createdBy: "Hannah Reyes", createdAt: "May 18, 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SAL-005", initials: "DT", name: "David Thompson", gradient: G.blue,
|
||||
priority: "high", status: "appointed", tag: "Storm Zone", updated: "1d ago", setter: "Travis",
|
||||
storm: { zone: "E Plano / Spring Creek Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(469) 504-1148", type: "Mobile", primary: true },
|
||||
{ number: "(214) 604-2148", type: "Work" },
|
||||
],
|
||||
emails: [{ address: "david.thompson@gmail.com", primary: true }],
|
||||
property: { address: "5130 Spring Creek Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Dalton Pruitt",
|
||||
notes: "Adjuster appointment confirmed for next week. Bring hail map + photos.",
|
||||
},
|
||||
insurance: {
|
||||
company: "Liberty Mutual", claimStatus: "Filed", claimNumber: "CLM-2026-1148",
|
||||
policyNumber: "POL-081148", adjusterName: "Priya Nair", adjusterPhone: "(972) 700-3148",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Dalton Pruitt", priority: "High", followUp: "Jun 9, 2026",
|
||||
createdBy: "Travis Boone", createdAt: "May 30, 2026",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Header stat — the full book is larger than the loaded page.
|
||||
export const TOTAL_LEADS = 35;
|
||||
|
||||
export const STATUS_META: Record<LeadStatus, { label: string; tone: string }> = {
|
||||
new: { label: "New", tone: "blue" },
|
||||
contacted: { label: "Contacted", tone: "orange" },
|
||||
appointed: { label: "Appointed", tone: "purple" },
|
||||
closed: { label: "Closed", tone: "green" },
|
||||
};
|
||||
|
||||
export const PRIORITY_META: Record<LeadPriority, { label: string; tone: string }> = {
|
||||
high: { label: "High", tone: "red" },
|
||||
medium: { label: "Medium", tone: "orange" },
|
||||
low: { label: "Low", tone: "muted" },
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Reps + option lists — power the New Lead form */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type Rep = { id: string; initials: string; name: string; email: string };
|
||||
|
||||
export const REPS: Rep[] = [
|
||||
{ id: "LUP-1040", initials: "CT", name: "Cody Tatum", email: "cody.tatum@lynkeduppro.com" },
|
||||
{ id: "LUP-1041", initials: "HR", name: "Hannah Reyes", email: "hannah.reyes@lynkeduppro.com" },
|
||||
{ id: "LUP-1042", initials: "TB", name: "Travis Boone", email: "travis.boone@lynkeduppro.com" },
|
||||
{ id: "LUP-1043", initials: "SG", name: "Shelby Greer", email: "shelby.greer@lynkeduppro.com" },
|
||||
{ id: "LUP-1044", initials: "DP", name: "Dalton Pruitt", email: "dalton.pruitt@lynkeduppro.com" },
|
||||
];
|
||||
|
||||
export const LEAD_SOURCES = ["Door Knock", "Referral", "Storm Chase", "Mailer / Postcard", "Sign Call", "Insurance Agent Referral", "Repeat Customer", "Social Media", "Other"];
|
||||
export const LEAD_TYPES = ["Insurance", "Retail"];
|
||||
export const WORK_TYPES = ["Roof Replacement", "Roof Repair", "Inspection", "Gutter Install"];
|
||||
export const TRADE_TYPES = ["Roofing", "Gutters", "Siding", "Windows"];
|
||||
export const PROPERTY_TYPES = ["Single Family", "Multi Family", "Commercial"];
|
||||
export const CLAIM_STATUSES = ["Not Filed", "Filed", "Approved", "Paid", "Denied"];
|
||||
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Leads — storm-restoration pipeline board.
|
||||
// · Hero head : storm banner + headline stats (by status)
|
||||
// · Toolbar : search + status filter tabs
|
||||
// · Board : lead cards (avatar, priority ring, status,
|
||||
// address, phone, source, rep, updated)
|
||||
// · Detail : click a lead → rich popup with Contact,
|
||||
// Property, Job Details, Insurance, Assignment
|
||||
// Data comes from leads-data.ts (client-side mock).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui";
|
||||
import {
|
||||
LEADS, TOTAL_LEADS, STATUS_META, PRIORITY_META,
|
||||
REPS, LEAD_SOURCES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
|
||||
type Lead, type LeadStatus,
|
||||
} from "./leads-data";
|
||||
|
||||
const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "new", label: "New" },
|
||||
{ value: "contacted", label: "Contacted" },
|
||||
{ value: "appointed", label: "Appointed" },
|
||||
{ value: "closed", label: "Closed" },
|
||||
];
|
||||
|
||||
export function Leads() {
|
||||
const toast = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | LeadStatus>("all");
|
||||
const [selected, setSelected] = useState<Lead | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
const countByStatus = useMemo(() => {
|
||||
const m: Record<string, number> = {};
|
||||
for (const l of LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
|
||||
return m;
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return LEADS.filter((l) => {
|
||||
const matchStatus = filter === "all" || l.status === filter;
|
||||
const hay = `${l.name} ${l.property.address} ${l.property.city} ${l.job.source} ${l.job.canvasser}`.toLowerCase();
|
||||
return matchStatus && (!q || hay.includes(q));
|
||||
});
|
||||
}, [query, filter]);
|
||||
|
||||
return (
|
||||
<div className="view leads">
|
||||
<PageHead
|
||||
eyebrow="Sales"
|
||||
title="Leads"
|
||||
subtitle={`${TOTAL_LEADS} total leads · Plano hail zone · storm 2026-04-28`}
|
||||
icon="leads"
|
||||
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New Lead</Btn>}
|
||||
/>
|
||||
|
||||
{/* ---- stat strip ---------------------------------------- */}
|
||||
<div className="leads-stats">
|
||||
<StatCard label="Total leads" value={TOTAL_LEADS} icon="leads" tone="orange" />
|
||||
<StatCard label="New" value={countByStatus.new ?? 0} icon="star" tone="blue" />
|
||||
<StatCard label="Contacted" value={countByStatus.contacted ?? 0} icon="phone" tone="orange" />
|
||||
<StatCard label="Appointed" value={countByStatus.appointed ?? 0} icon="clock" tone="purple" />
|
||||
<StatCard label="Closed" value={countByStatus.closed ?? 0} icon="check-circle" tone="green" />
|
||||
</div>
|
||||
|
||||
{/* ---- toolbar ------------------------------------------- */}
|
||||
<div className="leads-toolbar">
|
||||
<div className="leads-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name, address, source…"
|
||||
aria-label="Search leads"
|
||||
/>
|
||||
{query && <button className="leads-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||||
</div>
|
||||
<div className="leads-tabs" role="tablist">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
role="tab"
|
||||
aria-selected={filter === t.value}
|
||||
className={`leads-tab ${filter === t.value ? "active" : ""}`}
|
||||
onClick={() => setFilter(t.value)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- board --------------------------------------------- */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="card leads-empty">
|
||||
<Icon name="search" size={30} />
|
||||
<h3>No leads match</h3>
|
||||
<p>Try a different search or clear the status filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="leads-grid">
|
||||
{filtered.map((l) => (
|
||||
<LeadCard key={l.id} lead={l} onOpen={() => setSelected(l)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LeadDetail lead={selected} onClose={() => setSelected(null)} />
|
||||
<NewLead open={newOpen} onClose={() => setNewOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Stat card */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function StatCard({ label, value, icon, tone }: { label: string; value: number; icon: string; tone: string }) {
|
||||
return (
|
||||
<div className={`leads-stat tone-${tone}`}>
|
||||
<span className="leads-stat-ic"><Icon name={icon} size={17} /></span>
|
||||
<div className="leads-stat-body">
|
||||
<div className="leads-stat-val">{value}</div>
|
||||
<div className="leads-stat-lbl">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Lead card */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
|
||||
const status = STATUS_META[lead.status];
|
||||
const priority = PRIORITY_META[lead.priority];
|
||||
const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0];
|
||||
|
||||
return (
|
||||
<button className="lead-card" onClick={onOpen}>
|
||||
<div className="lead-card-top">
|
||||
<span className={`lead-ava prio-${lead.priority}`}>
|
||||
<Avatar initials={lead.initials} gradient={lead.gradient} size={46} />
|
||||
</span>
|
||||
<div className="lead-card-id">
|
||||
<div className="lead-card-name">{lead.name}</div>
|
||||
<div className="lead-card-sub"><span className="lead-code">{lead.id}</span> · <Icon name="storm" size={12} /> {lead.tag}</div>
|
||||
</div>
|
||||
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
||||
</div>
|
||||
|
||||
<div className="lead-card-row"><Icon name="pin" size={14} /><span>{lead.property.address}, {lead.property.city}, {lead.property.state}</span></div>
|
||||
<div className="lead-card-row"><Icon name="phone" size={14} /><span>{primaryPhone?.number}</span></div>
|
||||
|
||||
<div className="lead-card-foot">
|
||||
<Pill tone={status.tone}>{status.label}</Pill>
|
||||
<span className="lead-source"><Icon name="pin" size={12} /> {lead.job.source}</span>
|
||||
<span className="lead-card-spacer" />
|
||||
<span className="lead-rep" title={`Canvasser: ${lead.job.canvasser}`}>{lead.job.canvasser}</span>
|
||||
<span className="lead-updated">{lead.updated}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Lead detail popup */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function LeadDetail({ lead, onClose }: { lead: Lead | null; onClose: () => void }) {
|
||||
if (!lead) return null;
|
||||
const status = STATUS_META[lead.status];
|
||||
const priority = PRIORITY_META[lead.priority];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!lead}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={lead.name}
|
||||
subtitle={`${lead.id} · ${lead.tag}`}
|
||||
icon="leads"
|
||||
footer={
|
||||
<>
|
||||
<Btn variant="ghost" icon="phone">Call</Btn>
|
||||
<Btn variant="outline" icon="mail">Email</Btn>
|
||||
<Btn icon="check-circle">Update Status</Btn>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lead-detail">
|
||||
{/* identity strip */}
|
||||
<div className="ld-identity">
|
||||
<Avatar initials={lead.initials} gradient={lead.gradient} size={54} />
|
||||
<div className="ld-identity-body">
|
||||
<div className="ld-identity-name">{lead.name}</div>
|
||||
<div className="ld-identity-pills">
|
||||
<Pill tone={status.tone}>{status.label}</Pill>
|
||||
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* storm banner */}
|
||||
<div className="ld-storm">
|
||||
<span className="ld-storm-ic"><Icon name="storm" size={18} /></span>
|
||||
<div>
|
||||
<div className="ld-storm-zone">{lead.storm.zone}</div>
|
||||
<div className="ld-storm-meta">{lead.storm.date} · {lead.storm.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ld-grid">
|
||||
{/* Contact */}
|
||||
<Section title="Contact" icon="user">
|
||||
<div className="ld-sublabel">Phone Numbers</div>
|
||||
{lead.phones.map((p, i) => (
|
||||
<div className="ld-contact-row" key={i}>
|
||||
<Icon name="phone" size={14} />
|
||||
<span className="ld-contact-val">{p.number}</span>
|
||||
<span className="ld-contact-tag">{p.type}</span>
|
||||
{p.primary && <Pill tone="green">Primary</Pill>}
|
||||
</div>
|
||||
))}
|
||||
<div className="ld-sublabel">Email Addresses</div>
|
||||
{lead.emails.map((e, i) => (
|
||||
<div className="ld-contact-row" key={i}>
|
||||
<Icon name="mail" size={14} />
|
||||
<span className="ld-contact-val">{e.address}</span>
|
||||
{e.primary && <Pill tone="green">Primary</Pill>}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* Property */}
|
||||
<Section title="Property" icon="owners">
|
||||
<Dl label="Address" value={lead.property.address} />
|
||||
<Dl label="City" value={lead.property.city} />
|
||||
<Dl label="State" value={lead.property.state} />
|
||||
<Dl label="ZIP" value={lead.property.zip} />
|
||||
<Dl label="Property Type" value={lead.property.type} />
|
||||
</Section>
|
||||
|
||||
{/* Job Details */}
|
||||
<Section title="Job Details" icon="projects">
|
||||
<Dl label="Lead Source" value={lead.job.source} />
|
||||
<Dl label="Lead Type" value={lead.job.leadType} />
|
||||
<Dl label="Work Type" value={lead.job.workType} />
|
||||
<Dl label="Trade Type" value={lead.job.tradeType} />
|
||||
<Dl label="Urgency" value={lead.job.urgency} />
|
||||
<Dl label="Canvasser" value={lead.job.canvasser} />
|
||||
<div className="ld-notes">
|
||||
<div className="ld-sublabel">Field Notes</div>
|
||||
<p>{lead.job.notes}</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Insurance */}
|
||||
<Section title="Insurance" icon="shield">
|
||||
<Dl label="Insurance Company" value={lead.insurance.company} />
|
||||
<Dl label="Claim Status" value={lead.insurance.claimStatus} />
|
||||
<Dl label="Claim Number" value={lead.insurance.claimNumber} />
|
||||
<Dl label="Policy Number" value={lead.insurance.policyNumber} />
|
||||
<Dl label="Adjuster Name" value={lead.insurance.adjusterName} />
|
||||
<Dl label="Adjuster Phone" value={lead.insurance.adjusterPhone} />
|
||||
</Section>
|
||||
|
||||
{/* Assignment */}
|
||||
<Section title="Assignment" icon="team" wide>
|
||||
<div className="ld-assign">
|
||||
<Dl label="Assigned To" value={lead.assignment.assignedTo} />
|
||||
<Dl label="Priority" value={lead.assignment.priority} />
|
||||
<Dl label="Follow-Up Date" value={lead.assignment.followUp} />
|
||||
<Dl label="Created By" value={lead.assignment.createdBy} />
|
||||
<Dl label="Created At" value={lead.assignment.createdAt} />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, icon, children, wide }: { title: string; icon: string; children: ReactNode; wide?: boolean }) {
|
||||
return (
|
||||
<div className={`ld-section ${wide ? "wide" : ""}`}>
|
||||
<div className="ld-section-head"><Icon name={icon} size={15} /> {title}</div>
|
||||
<div className="ld-section-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dl({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="ld-dl">
|
||||
<span className="ld-dl-k">{label}</span>
|
||||
<span className="ld-dl-v">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* New Lead — Quick / Full Form intake */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
type Priority = "Low" | "Medium" | "High";
|
||||
type Urgency = "Standard" | "High" | "Emergency";
|
||||
type PhoneRow = { number: string; type: string };
|
||||
type EmailRow = { address: string };
|
||||
|
||||
const FULL_STEPS = [
|
||||
{ value: "contact", label: "Contact", icon: "user" },
|
||||
{ value: "property", label: "Property", icon: "owners" },
|
||||
{ value: "job", label: "Job Details", icon: "projects" },
|
||||
{ value: "insurance", label: "Insurance", icon: "shield" },
|
||||
{ value: "assignment", label: "Assignment", icon: "team" },
|
||||
];
|
||||
|
||||
const LEAD_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family"];
|
||||
const PROPERTY_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family", "Industrial"];
|
||||
|
||||
const BLANK = {
|
||||
firstName: "", lastName: "",
|
||||
phones: [{ number: "", type: "Mobile" }] as PhoneRow[],
|
||||
emails: [] as EmailRow[],
|
||||
address: "", city: "", state: "TX", zip: "", propertyType: "",
|
||||
photos: [] as string[],
|
||||
source: "", referralNote: "", canvasser: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "",
|
||||
insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "",
|
||||
assignRep: "", priority: "Medium" as Priority, followUp: "",
|
||||
};
|
||||
|
||||
function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
const [mode, setMode] = useState<"quick" | "full">("quick");
|
||||
const [section, setSection] = useState("contact");
|
||||
const [f, setF] = useState({ ...BLANK });
|
||||
|
||||
const set = (k: string) => (e: { target: { value: string } }) =>
|
||||
setF((s) => ({ ...s, [k]: e.target.value }) as typeof BLANK);
|
||||
|
||||
// multi-value handlers
|
||||
const addPhone = () => setF((s) => ({ ...s, phones: [...s.phones, { number: "", type: "Mobile" }] }));
|
||||
const setPhone = (i: number, key: "number" | "type", v: string) => setF((s) => ({ ...s, phones: s.phones.map((p, j) => (j === i ? { ...p, [key]: v } : p)) }));
|
||||
const removePhone = (i: number) => setF((s) => ({ ...s, phones: s.phones.filter((_, j) => j !== i) }));
|
||||
const addEmail = () => setF((s) => ({ ...s, emails: [...s.emails, { address: "" }] }));
|
||||
const setEmail = (i: number, v: string) => setF((s) => ({ ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) }));
|
||||
const removeEmail = (i: number) => setF((s) => ({ ...s, emails: s.emails.filter((_, j) => j !== i) }));
|
||||
const addPhoto = () => setF((s) => ({ ...s, photos: [...s.photos, `Photo ${s.photos.length + 1}`] }));
|
||||
|
||||
function reset() { setF({ ...BLANK, phones: [{ number: "", type: "Mobile" }], emails: [], photos: [] }); setMode("quick"); setSection("contact"); }
|
||||
function close() { reset(); onClose(); }
|
||||
|
||||
function submit() {
|
||||
const name = `${f.firstName} ${f.lastName}`.trim();
|
||||
if (!name) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; }
|
||||
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
|
||||
close();
|
||||
}
|
||||
|
||||
const repOptions = [{ id: "", initials: "—", name: "Unassigned" }, ...REPS];
|
||||
|
||||
const stepIdx = FULL_STEPS.findIndex((s) => s.value === section);
|
||||
const isFirstStep = stepIdx <= 0;
|
||||
const isLastStep = stepIdx === FULL_STEPS.length - 1;
|
||||
const goNext = () => { if (!isLastStep) setSection(FULL_STEPS[stepIdx + 1].value); };
|
||||
const goBack = () => { if (!isFirstStep) setSection(FULL_STEPS[stepIdx - 1].value); };
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
size="lg"
|
||||
title="New Lead"
|
||||
subtitle="Full lead profile with insurance and assignment details."
|
||||
icon="plus"
|
||||
footer={
|
||||
mode === "full" ? (
|
||||
<>
|
||||
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
||||
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
|
||||
{isLastStep
|
||||
? <Btn icon="check" onClick={submit}>Create Lead</Btn>
|
||||
: <Btn icon="arrow" onClick={goNext}>Next</Btn>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
||||
<Btn icon="check" onClick={submit}>Create Lead</Btn>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="nl-form">
|
||||
<Segmented
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as "quick" | "full")}
|
||||
options={[{ value: "quick", label: "Quick", icon: "star" }, { value: "full", label: "Full Form", icon: "edit" }]}
|
||||
/>
|
||||
|
||||
{mode === "quick" ? (
|
||||
<div className="nl-grid">
|
||||
<Field label="First name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
|
||||
<Field label="Last name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
|
||||
<Field label="Phone"><input className="ds-input" value={f.phones[0]?.number ?? ""} onChange={(e) => setPhone(0, "number", e.target.value)} placeholder="(555) 000-0000" /></Field>
|
||||
<div className="nl-full"><Field label="Street address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
|
||||
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
|
||||
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} /></Field>
|
||||
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
|
||||
<Field label="Lead source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
|
||||
{f.source === "Referral" && (
|
||||
<div className="nl-full"><Field label="Referral note"><textarea className="ds-textarea" rows={3} value={f.referralNote} onChange={set("referralNote")} placeholder="Who referred this lead? Any details…" /></Field></div>
|
||||
)}
|
||||
{f.source === "Door Knock" && (
|
||||
<div className="nl-full"><Field label="Canvasser"><CanvasserSearch value={f.canvasser} onChange={(v) => setF((s) => ({ ...s, canvasser: v }))} options={REPS} /></Field></div>
|
||||
)}
|
||||
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
|
||||
<Field label="Follow-up date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SegTabs
|
||||
value={section}
|
||||
onChange={setSection}
|
||||
tabs={FULL_STEPS}
|
||||
/>
|
||||
|
||||
{section === "contact" && (
|
||||
<div className="nl-grid">
|
||||
<Field label="First Name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
|
||||
<Field label="Last Name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
|
||||
|
||||
<div className="nl-full">
|
||||
<div className="ds-field-lbl">Phone Numbers</div>
|
||||
{f.phones.map((p, i) => (
|
||||
<div className="nl-multirow" key={i}>
|
||||
<input className="ds-input" value={p.number} onChange={(e) => setPhone(i, "number", e.target.value)} placeholder="(555) 000-0000" />
|
||||
<select className="ds-select nl-typesel" value={p.type} onChange={(e) => setPhone(i, "type", e.target.value)}>
|
||||
{["Mobile", "Home", "Work"].map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
{f.phones.length > 1 && <button type="button" className="nl-rowx" aria-label="Remove phone" onClick={() => removePhone(i)}><Icon name="trash" size={15} /></button>}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="nl-add" onClick={addPhone}><Icon name="plus" size={14} /> Add Phone</button>
|
||||
</div>
|
||||
|
||||
<div className="nl-full">
|
||||
<div className="ds-field-lbl">Email Addresses</div>
|
||||
{f.emails.length === 0 && <div className="nl-empty">No emails added yet.</div>}
|
||||
{f.emails.map((em, i) => (
|
||||
<div className="nl-multirow" key={i}>
|
||||
<input className="ds-input" type="email" value={em.address} onChange={(e) => setEmail(i, e.target.value)} placeholder="name@email.com" />
|
||||
<button type="button" className="nl-rowx" aria-label="Remove email" onClick={() => removeEmail(i)}><Icon name="trash" size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="nl-add" onClick={addEmail}><Icon name="plus" size={14} /> Add Email</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "property" && (
|
||||
<div className="nl-grid">
|
||||
<div className="nl-full"><Field label="Street Address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
|
||||
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
|
||||
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} placeholder="TX" /></Field>
|
||||
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
|
||||
<Field label="Property Type"><Select value={f.propertyType} onChange={set("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
|
||||
<div className="nl-full">
|
||||
<div className="ds-field-lbl">Site Photos</div>
|
||||
<button type="button" className="nl-photos" onClick={addPhoto}>
|
||||
<Icon name="camera" size={22} />
|
||||
<span className="nl-photos-t">Tap to add photos</span>
|
||||
<span className="nl-photos-s">Camera · Gallery · Multiple allowed</span>
|
||||
</button>
|
||||
{f.photos.length > 0 && (
|
||||
<div className="nl-photo-chips">
|
||||
{f.photos.map((p, i) => <span key={i} className="nl-photo-chip"><Icon name="check" size={12} /> {p}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "job" && (
|
||||
<div className="nl-grid">
|
||||
<Field label="Lead Source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
|
||||
<Field label="Lead Type"><Select value={f.leadType} onChange={set("leadType")} options={LEAD_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
|
||||
<Field label="Work Type"><Select value={f.workType} onChange={set("workType")} options={WORK_TYPES} placeholder="Roof Replacement, Repair…" /></Field>
|
||||
<Field label="Trade Type"><Select value={f.tradeType} onChange={set("tradeType")} options={TRADE_TYPES} placeholder="Roofing, Gutter, Siding…" /></Field>
|
||||
<div className="nl-full"><UrgencyPicker value={f.urgency} onChange={(u) => setF((s) => ({ ...s, urgency: u }))} /></div>
|
||||
<div className="nl-full"><Field label="Notes"><textarea className="ds-textarea" rows={3} value={f.notes} onChange={set("notes")} placeholder="First impression, visible damage, special circumstances…" /></Field></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "insurance" && (
|
||||
<div className="nl-grid">
|
||||
<div className="nl-full"><Field label="Insurance Company"><input className="ds-input" value={f.insCompany} onChange={set("insCompany")} placeholder="State Farm" /></Field></div>
|
||||
<Field label="Claim Number"><input className="ds-input" value={f.claimNumber} onChange={set("claimNumber")} placeholder="e.g. CLM-2026-00482" /></Field>
|
||||
<Field label="Claim Status"><Select value={f.claimStatus} onChange={set("claimStatus")} options={CLAIM_STATUSES} placeholder="Select status…" /></Field>
|
||||
<Field label="Adjuster Name"><input className="ds-input" value={f.adjusterName} onChange={set("adjusterName")} placeholder="Full name" /></Field>
|
||||
<Field label="Adjuster Phone"><input className="ds-input" value={f.adjusterPhone} onChange={set("adjusterPhone")} placeholder="(555) 000-0000" /></Field>
|
||||
<div className="nl-full"><Field label="Policy Number"><input className="ds-input" value={f.policyNumber} onChange={set("policyNumber")} placeholder="e.g. POL-7734892-A" /></Field></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "assignment" && (
|
||||
<div className="nl-grid">
|
||||
<div className="nl-full"><Field label="Assign Rep"><RepSelect value={f.assignRep} onChange={set("assignRep")} options={repOptions} /></Field></div>
|
||||
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
|
||||
<Field label="Follow-up Date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ value, onChange, options, placeholder }: { value: string; onChange: (e: { target: { value: string } }) => void; options: string[]; placeholder?: string }) {
|
||||
return (
|
||||
<select className={`ds-select ${!value && placeholder ? "is-placeholder" : ""}`} value={value} onChange={onChange}>
|
||||
{placeholder && <option value="" disabled>{placeholder}</option>}
|
||||
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function RepSelect({ value, onChange, options }: { value: string; onChange: (e: { target: { value: string } }) => void; options: { id: string; initials: string; name: string }[] }) {
|
||||
return (
|
||||
<select className="ds-select" value={value} onChange={onChange}>
|
||||
{options.map((r) => <option key={r.id || "none"} value={r.id}>{r.id ? `${r.name} · ${r.id}` : "— Unassigned"}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasserSearch({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { id: string; initials: string; name: string; email: string }[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((o) => o.id === value);
|
||||
const matches = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((o) => o.name.toLowerCase().includes(q) || o.email.toLowerCase().includes(q));
|
||||
}, [query, options]);
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<div className="nl-canvasser-chip">
|
||||
<Avatar initials={selected.initials} size={28} />
|
||||
<span className="nl-canvasser-name">{selected.name}</span>
|
||||
<span className="nl-canvasser-email">{selected.email}</span>
|
||||
<button type="button" className="nl-canvasser-clear" onClick={() => { onChange(""); setQuery(""); }} aria-label="Clear canvasser"><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nl-canvasser">
|
||||
<input
|
||||
className="ds-input"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder="Search canvasser by name or email"
|
||||
/>
|
||||
{open && matches.length > 0 && (
|
||||
<div className="nl-canvasser-menu">
|
||||
{matches.map((o) => (
|
||||
<button key={o.id} type="button" className="nl-canvasser-opt" onMouseDown={(e) => { e.preventDefault(); onChange(o.id); setQuery(""); setOpen(false); }}>
|
||||
<Avatar initials={o.initials} size={26} />
|
||||
<span className="nl-canvasser-name">{o.name}</span>
|
||||
<span className="nl-canvasser-email">{o.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open && matches.length === 0 && (
|
||||
<div className="nl-canvasser-menu"><div className="nl-canvasser-empty">No canvasser found</div></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityPicker({ value, onChange }: { value: Priority; onChange: (p: Priority) => void }) {
|
||||
return (
|
||||
<div className="ds-field">
|
||||
<span className="ds-field-lbl">Priority</span>
|
||||
<div className="nl-prio">
|
||||
{(["Low", "Medium", "High"] as Priority[]).map((p) => (
|
||||
<button key={p} type="button" className={`nl-prio-btn ${value === p ? `active ${p.toLowerCase()}` : ""}`} onClick={() => onChange(p)}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UrgencyPicker({ value, onChange }: { value: Urgency; onChange: (u: Urgency) => void }) {
|
||||
return (
|
||||
<div className="ds-field">
|
||||
<span className="ds-field-lbl">Urgency</span>
|
||||
<div className="nl-prio">
|
||||
{(["Standard", "High", "Emergency"] as Urgency[]).map((u) => (
|
||||
<button key={u} type="button" className={`nl-prio-btn urg ${value === u ? `active ${u.toLowerCase()}` : ""}`} onClick={() => onChange(u)}>{u}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,39 +5,18 @@
|
||||
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
|
||||
// demo path = the SDK's own MockAdapter.
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { MessageSocket } from "@insignia/iios-kernel-client";
|
||||
import { useMemo } from "react";
|
||||
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
|
||||
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
|
||||
import "@insignia/iios-messaging-ui/styles.css";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
import { useRealtime } from "@/lib/realtime";
|
||||
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
|
||||
|
||||
interface RealtimeDTO { url: string; audience: string; token?: string }
|
||||
|
||||
/** Open the IIOS message socket with the delegated token the BFF mints (crm.messenger.realtime). */
|
||||
function useRealtimeSocket(): MessageSocket | null {
|
||||
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
|
||||
const [socket, setSocket] = useState<MessageSocket | null>(null);
|
||||
const url = rt.data?.url;
|
||||
const token = rt.data?.token;
|
||||
useEffect(() => {
|
||||
if (!url || !token) return;
|
||||
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
|
||||
s.connect();
|
||||
setSocket(s);
|
||||
return () => {
|
||||
s.disconnect();
|
||||
setSocket(null);
|
||||
};
|
||||
}, [url, token]);
|
||||
return socket;
|
||||
}
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function MessengerSdk() {
|
||||
export function MessengerSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
|
||||
return (
|
||||
<div className="view">
|
||||
{!SHELL && (
|
||||
@@ -55,26 +34,26 @@ export function MessengerSdk() {
|
||||
Demo mode — running on the SDK's mock adapter.
|
||||
</div>
|
||||
)}
|
||||
<div className="miu-host">{SHELL ? <LiveHost /> : <DemoHost />}</div>
|
||||
<div className="miu-host">{SHELL ? <LiveHost focusThreadId={focusThreadId} /> : <DemoHost focusThreadId={focusThreadId} />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SHELL is a build-time constant, so exactly one of these mounts for the life of the app
|
||||
// (Rules-of-Hooks safe — the other branch never renders).
|
||||
function DemoHost() {
|
||||
function DemoHost({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
|
||||
return (
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<SdkMessenger />
|
||||
<SdkMessenger focusThreadId={focusThreadId} />
|
||||
</MessagingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveHost() {
|
||||
function LiveHost({ focusThreadId }: { focusThreadId?: string | null }) {
|
||||
const { sdk } = useAppShell();
|
||||
const { user } = useAuth();
|
||||
const socket = useRealtimeSocket();
|
||||
const socket = useRealtime();
|
||||
// Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live.
|
||||
const adapter = useMemo<MessagingAdapter | null>(
|
||||
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null),
|
||||
@@ -83,7 +62,7 @@ function LiveHost() {
|
||||
if (!adapter) return <div className="miu-empty">Loading…</div>;
|
||||
return (
|
||||
<MessagingProvider adapter={adapter}>
|
||||
<SdkMessenger />
|
||||
<SdkMessenger focusThreadId={focusThreadId} />
|
||||
</MessagingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
// Topbar bell → offline-notification control. Click opens a small popover to enable/disable Web Push
|
||||
// for this browser. The dot is lit when this browser is subscribed. Hidden entirely when push isn't
|
||||
// available (demo mode, or a browser without ServiceWorker/PushManager).
|
||||
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./ui";
|
||||
import { usePushNotifications } from "@/lib/push-notifications";
|
||||
|
||||
export function NotificationBell() {
|
||||
const push = usePushNotifications();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!push.supported) return null;
|
||||
|
||||
const denied = push.permission === "denied";
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
className="ic-btn"
|
||||
aria-label="Notifications"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
style={{ position: "relative" }}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<Icon name="bell" size={18} />
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 9,
|
||||
right: 10,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 99,
|
||||
background: push.subscribed ? "var(--orange)" : "var(--border)",
|
||||
border: "2px solid var(--panel)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
|
||||
<div className="tm-menu-pop" role="menu" style={{ width: 260, padding: 14 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>Offline notifications</div>
|
||||
<p style={{ fontSize: 12, color: "var(--muted)", margin: "0 0 12px", lineHeight: 1.4 }}>
|
||||
{push.subscribed
|
||||
? "You'll get push notifications for new direct messages and mentions, even when this tab is closed."
|
||||
: "Get notified about direct messages and mentions when the CRM isn't open."}
|
||||
</p>
|
||||
|
||||
{denied ? (
|
||||
<p style={{ fontSize: 12, color: "var(--danger, #c0392b)", margin: 0 }}>
|
||||
Notifications are blocked in your browser settings. Allow them for this site, then try again.
|
||||
</p>
|
||||
) : push.subscribed ? (
|
||||
<button className="ds-btn v-ghost full" disabled={push.busy} onClick={() => push.disable()}>
|
||||
{push.busy ? "Turning off…" : "Turn off notifications"}
|
||||
</button>
|
||||
) : (
|
||||
<button className="ds-btn v-primary full" disabled={push.busy} onClick={() => push.enable()}>
|
||||
{push.busy ? "Enabling…" : "Enable notifications"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{push.error && <p style={{ fontSize: 11.5, color: "var(--danger, #c0392b)", margin: "10px 0 0" }}>{push.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
// App-wide in-app notifications. Uses the shared dashboard socket to watch activity across ALL of
|
||||
// the user's threads (via the adapter's subscribeActivity) and shows a clickable toast when a new
|
||||
// message arrives — unless you're already on the Messenger tab (you'd see it live there). Clicking
|
||||
// deep-links to the conversation. Renders nothing.
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import type { MessagingAdapter } from "@insignia/iios-messaging-ui";
|
||||
import { isShellConfigured } from "@/lib/appshell";
|
||||
import { useRealtime } from "@/lib/realtime";
|
||||
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
|
||||
import { useToast } from "./ui";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function NotificationCenter({ active, onNavigate }: { active: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
||||
const socket = useRealtime();
|
||||
const { sdk } = useAppShell();
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const me = user?.id;
|
||||
|
||||
// Keep the current tab readable inside the (stable) subscription callback.
|
||||
const activeRef = useRef(active);
|
||||
activeRef.current = active;
|
||||
|
||||
const adapter = useMemo<MessagingAdapter | null>(
|
||||
() => (me && socket ? new CrmMessagingAdapter(sdk as unknown as DataDoor, me, socket) : null),
|
||||
[sdk, me, socket],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SHELL || !adapter?.subscribeActivity) return;
|
||||
return adapter.subscribeActivity(({ threadId, message }) => {
|
||||
if (message.actorId === me) return; // never notify me about my own message
|
||||
if (activeRef.current === "messenger") return; // already watching chat live
|
||||
toast.push({
|
||||
tone: "info",
|
||||
title: "New message",
|
||||
desc: message.text?.slice(0, 90) || "You have a new message",
|
||||
onClick: () => onNavigate("messenger", threadId),
|
||||
});
|
||||
});
|
||||
}, [adapter, me, toast, onNavigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -12,9 +12,11 @@
|
||||
import { useState } from "react";
|
||||
import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui";
|
||||
import { useSmsSettings } from "@/lib/sms-settings-api";
|
||||
import { useSmtpSettings } from "@/lib/smtp-settings-api";
|
||||
|
||||
const SID_RE = /^AC[0-9a-fA-F]{32}$/;
|
||||
const E164_RE = /^\+[1-9]\d{6,14}$/;
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
export function Settings() {
|
||||
return (
|
||||
@@ -29,7 +31,7 @@ export function Settings() {
|
||||
<h3 className="settings-section-title">Integrations</h3>
|
||||
<div className="settings-grid">
|
||||
<TwilioCard />
|
||||
<SmtpComingSoon />
|
||||
<SmtpCard />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -120,17 +122,101 @@ function TwilioCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function SmtpComingSoon() {
|
||||
function SmtpCard() {
|
||||
const toast = useToast();
|
||||
const { status, loading, live, configure } = useSmtpSettings();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("587");
|
||||
const [secure, setSecure] = useState(false);
|
||||
const [user, setUser] = useState("");
|
||||
const [pass, setPass] = useState("");
|
||||
const [fromEmail, setFromEmail] = useState("");
|
||||
const [fromName, setFromName] = useState("");
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const showForm = editing || (!loading && !status.configured);
|
||||
|
||||
function validate(): boolean {
|
||||
const e: Record<string, string> = {};
|
||||
if (!host.trim()) e.host = "SMTP host is required.";
|
||||
const p = Number(port);
|
||||
if (!Number.isInteger(p) || p < 1 || p > 65535) e.port = "Port must be 1–65535.";
|
||||
if (!user.trim()) e.user = "Username is required.";
|
||||
if (!pass.trim()) e.pass = "Password is required.";
|
||||
if (!EMAIL_RE.test(fromEmail.trim())) e.fromEmail = "A valid from-address is required.";
|
||||
setErrors(e);
|
||||
return Object.keys(e).length === 0;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await configure({ host: host.trim(), port: Number(port), secure, user: user.trim(), pass: pass.trim(), fromEmail: fromEmail.trim(), ...(fromName.trim() ? { fromName: fromName.trim() } : {}) });
|
||||
toast.push({ tone: "success", title: "SMTP connected", desc: "Outbound email now sends from your server." });
|
||||
setHost(""); setPort("587"); setSecure(false); setUser(""); setPass(""); setFromEmail(""); setFromName(""); setErrors({}); setEditing(false);
|
||||
} catch (err) {
|
||||
toast.push({ tone: "error", title: "Couldn't save SMTP settings", desc: (err as Error).message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-card is-soon">
|
||||
<div className="settings-card">
|
||||
<div className="settings-card-head">
|
||||
<span className="settings-card-ic" aria-hidden="true"><Icon name="mail" size={20} /></span>
|
||||
<div className="settings-card-titles">
|
||||
<div className="settings-card-name">Email <span className="settings-card-sub">· SMTP</span></div>
|
||||
<div className="settings-card-desc">Bring your own SMTP server for outbound email.</div>
|
||||
<div className="settings-card-desc">Send external email from your own mail server.</div>
|
||||
</div>
|
||||
<Pill tone="muted">Coming soon</Pill>
|
||||
{status.configured ? <Pill tone="green">Connected</Pill> : <Pill tone="muted">Not connected</Pill>}
|
||||
</div>
|
||||
|
||||
{status.configured && !editing ? (
|
||||
<div className="settings-card-body">
|
||||
<dl className="settings-kv">
|
||||
<div><dt>From</dt><dd>{status.fromName ? `${status.fromName} · ` : ""}{status.fromEmail ?? "—"}</dd></div>
|
||||
<div><dt>Server</dt><dd>{status.host ?? "—"}{status.port ? `:${status.port}` : ""}</dd></div>
|
||||
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
|
||||
</dl>
|
||||
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showForm ? (
|
||||
<div className="settings-card-body">
|
||||
<Field label="SMTP host" required error={errors.host} hint="e.g. smtp.sendgrid.net or your mail server.">
|
||||
<input className="ds-input" value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="Port" required error={errors.port} hint="587 (STARTTLS) or 465 (SSL).">
|
||||
<input className="ds-input" value={port} onChange={(e) => setPort(e.target.value)} placeholder="587" autoComplete="off" />
|
||||
</Field>
|
||||
<label className="settings-check">
|
||||
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} /> Use SSL/TLS (port 465)
|
||||
</label>
|
||||
<Field label="Username" required error={errors.user} hint="Often your email or an API key.">
|
||||
<input className="ds-input" value={user} onChange={(e) => setUser(e.target.value)} placeholder="apikey / user@example.com" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="Password" required error={errors.pass} hint="Encrypted on save and never shown again.">
|
||||
<input className="ds-input" type="password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder="••••••••••••" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="From address" required error={errors.fromEmail}>
|
||||
<input className="ds-input" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder="no-reply@example.com" autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="From name" hint="Optional display name on outgoing mail.">
|
||||
<input className="ds-input" value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder="Acme Roofing" autoComplete="off" />
|
||||
</Field>
|
||||
<div className="settings-card-actions">
|
||||
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect SMTP"}</Btn>
|
||||
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!live ? <div className="settings-card-note">Demo mode — credentials are stored locally and no email is sent.</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ key: "messenger", label: "Messenger", icon: "send", subtitle: "Chat with your team and clients" },
|
||||
{ key: "inbox", label: "Inbox", icon: "bell", subtitle: "Mentions, messages, alerts and mail — all in one" },
|
||||
{ key: "gallery", label: "Smart Gallery", icon: "gallery", subtitle: "Every photo and video for your jobs — searchable, editable and shareable" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -79,7 +80,15 @@ export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
|
||||
// brand-new user with no membership); every other item requires membership, and the
|
||||
// items mapped here additionally require the given permission. Unmapped items are
|
||||
// shown to any member. This is UX only — be-crm still enforces every action.
|
||||
const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox"]);
|
||||
//
|
||||
// `gallery` DECISION: it stays in ALWAYS_VISIBLE (nav row always shown to members) rather than
|
||||
// being gated here by `media.view`. The real access gate lives INSIDE the view (SmartGallery reads
|
||||
// `useGalleryFeatures().canView`), which uses the permissive "member with zero media.* perms → all
|
||||
// enabled" fallback. Gating the nav row here would use the stricter sidebar rule (member without the
|
||||
// perm → hidden) and so would hide the gallery from freshly-seeded members before an admin has
|
||||
// configured any Media perms — regressing the demo and the common member. So: always-visible row,
|
||||
// real gating in-view. (be-crm enforces data access regardless of what the nav shows.)
|
||||
const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox", "gallery"]);
|
||||
const NAV_PERMISSION: Record<string, string | undefined> = {
|
||||
team: "team.manage",
|
||||
people: "team.manage",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// The actual @photo-gallery/sdk mount. Split out of smart-gallery.tsx so it can be
|
||||
// loaded with next/dynamic({ ssr: false }) — the SDK is browser-only (matchMedia,
|
||||
// IndexedDB, Leaflet, canvas) and pulls in heavy optional ML models on demand, so it
|
||||
// must stay out of the dashboard's initial bundle and out of the server render.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { PhotoGallery, type ViewId } from "@photo-gallery/sdk";
|
||||
import { createCrmAIProvider } from "@/lib/gallery-ai";
|
||||
import {
|
||||
GALLERY_THEME_TOKENS,
|
||||
useGalleryFeatures,
|
||||
useGalleryLockProvider,
|
||||
useGalleryStorage,
|
||||
useGalleryUser,
|
||||
} from "@/lib/gallery-api";
|
||||
|
||||
import "@photo-gallery/sdk/styles.css";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
export interface SmartGalleryMountProps {
|
||||
/** The dashboard's current appearance — the gallery must never diverge from the host. */
|
||||
theme: "dark" | "light";
|
||||
}
|
||||
|
||||
export default function SmartGalleryMount({ theme }: SmartGalleryMountProps) {
|
||||
const { adapter } = useGalleryStorage();
|
||||
const currentUser = useGalleryUser();
|
||||
// Server-backed Recently Deleted lock when the Shell is wired; `undefined` in demo mode, which
|
||||
// leaves the SDK on its own device-local lock (see useGalleryLockProvider).
|
||||
const lockProvider = useGalleryLockProvider();
|
||||
// Feature toggles resolved from the caller's CRM permissions (Media group). Superadmins/owners and
|
||||
// the demo see everything; see useGalleryFeatures for the safe permissive fallback.
|
||||
const { features } = useGalleryFeatures();
|
||||
|
||||
// Sidebar rows + Collections sections the CRM never wants to surface. The SDK hides both the row and
|
||||
// the matching Collections section for each id. Screenshots + Documents aren't part of a roofing CRM.
|
||||
const hiddenViews: ViewId[] = ["screenshots", "sys:documents"];
|
||||
|
||||
// One provider per mount. Every model is dynamically imported inside it, so constructing
|
||||
// it is cheap; the weight only arrives when a photo is actually analyzed.
|
||||
const ai = useMemo(() => createCrmAIProvider(), []);
|
||||
|
||||
return (
|
||||
<PhotoGallery
|
||||
embedded
|
||||
adapter={adapter}
|
||||
ai={ai}
|
||||
// The CRM owns light/dark, so the in-gallery Appearance switcher is suppressed and the
|
||||
// theme is driven straight off the dashboard's own toggle.
|
||||
theme={theme}
|
||||
chrome={{ titlebar: false, themeSwitcher: false }}
|
||||
themeTokens={GALLERY_THEME_TOKENS}
|
||||
borderRadius={12}
|
||||
currentUser={currentUser}
|
||||
lockProvider={lockProvider}
|
||||
title="Smart Gallery"
|
||||
// The SDK's floating Info panel defaults to 64px from the top — the height of its
|
||||
// OWN toolbar. Inside the dashboard it has to clear the 84px CRM topbar instead.
|
||||
// `style` is applied after the token vars, so this wins over the SDK's inline default.
|
||||
style={{ ["--apg-overlay-top" as string]: "96px" }}
|
||||
hiddenViews={hiddenViews}
|
||||
features={features}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Smart Gallery — the tenant's media library, powered by @photo-gallery/sdk embedded
|
||||
// inside the dashboard shell. The SDK owns the gallery experience (grid, lightbox,
|
||||
// photo + video editors, map, people, versions, comments, AI); this file owns the
|
||||
// LynkedUp chrome around it: page head, demo-mode banner, sizing, and failure
|
||||
// containment so a gallery fault can never take the dashboard down.
|
||||
//
|
||||
// Storage + identity come from `@/lib/gallery-api` (be-crm data door when the Shell is
|
||||
// configured, device-local otherwise). Theming comes from GALLERY_THEME_TOKENS, which
|
||||
// maps the SDK's tokens onto this dashboard's own CSS variables.
|
||||
// ============================================================
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Icon } from "./ui";
|
||||
import { useGalleryFeatures, useGalleryStorage } from "@/lib/gallery-api";
|
||||
|
||||
const SmartGalleryMount = dynamic(() => import("./smart-gallery-mount"), {
|
||||
ssr: false,
|
||||
loading: () => <GalleryPlaceholder label="Loading your library…" />,
|
||||
});
|
||||
|
||||
export function SmartGallery({ theme }: { theme: "dark" | "light" }) {
|
||||
const { live } = useGalleryStorage();
|
||||
// Whole-view gate. `media.view` with the same permissive fallback the feature toggles use, so a
|
||||
// superadmin/owner and the demo always pass, and a member is only blocked if they hold some Media
|
||||
// perms but not `media.view`. The sidebar keeps the row visible (see sidebar.tsx §4) — the real
|
||||
// gate is here.
|
||||
const { canView } = useGalleryFeatures();
|
||||
|
||||
return (
|
||||
<div className="view gal">
|
||||
{/* Slim header — the big PageHead ate vertical space the gallery needs. The CRM topbar already
|
||||
shows the "Smart Gallery" title; this compact row (~40px) just adds context and the icon. */}
|
||||
<div className="gal-head">
|
||||
<span className="gal-head-ic">
|
||||
<Icon name="gallery" size={16} />
|
||||
</span>
|
||||
<h1 className="gal-head-title">Smart Gallery</h1>
|
||||
<span className="gal-head-sub">Every photo and video for your jobs — searchable, editable and shareable.</span>
|
||||
</div>
|
||||
|
||||
{!live && (
|
||||
<div className="gal-banner">
|
||||
<Icon name="info" size={14} />
|
||||
<span>Demo mode — stored on this device only. It goes live once the Shell + be-crm are connected.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canView ? (
|
||||
<div className="gal-shell">
|
||||
<GalleryBoundary>
|
||||
<SmartGalleryMount theme={theme} />
|
||||
</GalleryBoundary>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gal-shell">
|
||||
<div className="gal-placeholder">
|
||||
<span className="gal-placeholder-ic">
|
||||
<Icon name="lock" size={28} />
|
||||
</span>
|
||||
<h3>You don't have access to the gallery</h3>
|
||||
<p>Ask a workspace admin to grant you the “View Smart Gallery” permission.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function GalleryPlaceholder({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="gal-placeholder">
|
||||
<span className="gal-placeholder-ic">
|
||||
<Icon name="gallery" size={30} />
|
||||
</span>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gallery is a large third-party surface with canvas, workers and optional ML models. A render
|
||||
* fault inside it must degrade to a message rather than blanking the whole dashboard, so it gets its
|
||||
* own error boundary. (Error boundaries still require a class component in React 19.)
|
||||
*/
|
||||
class GalleryBoundary extends Component<{ children: ReactNode }, BoundaryState> {
|
||||
state: BoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): BoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error("[smart-gallery] render failed", error, info.componentStack);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
return (
|
||||
<div className="gal-placeholder gal-placeholder-error">
|
||||
<span className="gal-placeholder-ic">
|
||||
<Icon name="alert" size={30} />
|
||||
</span>
|
||||
<h3>The gallery could not be displayed</h3>
|
||||
<p>{error.message || "An unexpected error occurred."}</p>
|
||||
<button className="ds-btn v-outline s-sm" onClick={() => this.setState({ error: null })}>
|
||||
<Icon name="refresh" size={14} /> Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,15 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
|
||||
import { useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import { Icon } from "./ui";
|
||||
import { GlobalSearch } from "./global-search";
|
||||
import { NotificationBell } from "./notification-bell";
|
||||
import { user } from "./account-data";
|
||||
|
||||
function initialsOf(name: string): string {
|
||||
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||
}
|
||||
|
||||
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
|
||||
export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
|
||||
// When signed in through the Shell, show the real identity from the App Context
|
||||
// Envelope; otherwise fall back to the static demo user.
|
||||
const router = useRouter();
|
||||
@@ -35,14 +36,11 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<button className="ic-btn" aria-label="Search"><Icon name="search" size={18} /></button>
|
||||
<GlobalSearch onNavigate={onNavigate} />
|
||||
<button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}>
|
||||
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
<button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}>
|
||||
<Icon name="bell" size={18} />
|
||||
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
|
||||
</button>
|
||||
<NotificationBell />
|
||||
<div className="top-user-wrap" style={{ position: "relative" }}>
|
||||
<button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
|
||||
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
|
||||
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
|
||||
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
|
||||
UsersRound, Download, type LucideIcon,
|
||||
UsersRound, Image as ImageIcon, Images, File, FolderOpen, Download,
|
||||
Video, Play, LayoutGrid, ZoomIn, type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
@@ -49,7 +50,11 @@ const ICONS: Record<string, LucideIcon> = {
|
||||
storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
|
||||
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
|
||||
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
|
||||
team: UsersRound, dots: MoreHorizontal, download: Download,
|
||||
team: UsersRound, dots: MoreHorizontal,
|
||||
// media / gallery (also used by messenger.tsx, which already asks for image/file)
|
||||
image: ImageIcon, gallery: Images, file: File, folder: FolderOpen,
|
||||
download: Download, video: Video, play: Play, grid: LayoutGrid,
|
||||
filter: Filter, zoom: ZoomIn, sparkle: Sparkles, "map-pin": MapPin,
|
||||
};
|
||||
|
||||
export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) {
|
||||
@@ -263,7 +268,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
|
||||
/* Toast */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string };
|
||||
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string; onClick?: () => void };
|
||||
type ToastCtx = { push: (t: Omit<Toast, "id">) => void };
|
||||
const ToastContext = createContext<ToastCtx | null>(null);
|
||||
|
||||
@@ -286,9 +291,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
<div className="ds-toasts">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`ds-toast tone-${t.tone}`}>
|
||||
<div key={t.id} className={`ds-toast tone-${t.tone}${t.onClick ? " is-clickable" : ""}`}>
|
||||
<Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} />
|
||||
<div className="ds-toast-body">
|
||||
<div
|
||||
className="ds-toast-body"
|
||||
{...(t.onClick ? { role: "button", tabIndex: 0, onClick: () => { t.onClick?.(); setItems((s) => s.filter((x) => x.id !== t.id)); } } : {})}
|
||||
>
|
||||
<div className="ds-toast-title">{t.title}</div>
|
||||
{t.desc && <div className="ds-toast-desc">{t.desc}</div>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// ============================================================
|
||||
// LynkedUp Pro — Lead Verification mock data.
|
||||
// The verification desk: door-knocked / web leads move through
|
||||
// an identity + insurance verification workflow before they
|
||||
// become working deals. All client-side.
|
||||
// ============================================================
|
||||
|
||||
export type VStatus = "verified" | "in_progress" | "assigned" | "pending" | "unverified";
|
||||
|
||||
export type VActivity = { text: string; time: string; who: string };
|
||||
|
||||
export type VLead = {
|
||||
id: string;
|
||||
initials: string;
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
source: string;
|
||||
assignee: { initials: string; name: string } | null;
|
||||
status: VStatus;
|
||||
verification: string; // sub-status text
|
||||
created: string;
|
||||
// detail (optional — derived when absent)
|
||||
email?: string;
|
||||
createdAt?: string; // date + time
|
||||
verifiedAt?: string;
|
||||
notes?: string;
|
||||
activity?: VActivity[];
|
||||
};
|
||||
|
||||
export const V_STATUS_META: Record<VStatus, { label: string; tone: string }> = {
|
||||
verified: { label: "Verified", tone: "green" },
|
||||
in_progress: { label: "In Progress", tone: "orange" },
|
||||
assigned: { label: "Assigned", tone: "blue" },
|
||||
pending: { label: "Pending", tone: "purple" },
|
||||
unverified: { label: "Unverified", tone: "red" },
|
||||
};
|
||||
|
||||
const WADE = { initials: "WH", name: "Wade Hollis" };
|
||||
const DARLENE = { initials: "DB", name: "Darlene Brooks" };
|
||||
const ROY = { initials: "RS", name: "Roy Schaefer" };
|
||||
|
||||
export const V_LEADS: VLead[] = [
|
||||
{
|
||||
id: "LD-V-001", initials: "K", name: "Kevin Hartley", address: "2814 Ravenswood Dr, Plano, TX 75023", phone: "(972) 413-8902", source: "Door Knock", assignee: WADE, status: "verified", verification: "Verified", created: "May 14, 2026",
|
||||
email: "kevin.hartley@gmail.com", createdAt: "May 14, 2026, 2:40 PM", verifiedAt: "May 16, 2026, 7:55 PM",
|
||||
notes: "Ownership confirmed via county records. Hail damage from April 28 storm event verified.",
|
||||
activity: [
|
||||
{ text: "Lead submitted via door knock intake.", time: "May 14, 2026, 2:40 PM", who: "System" },
|
||||
{ text: "Assigned to Wade Hollis.", time: "May 14, 2026, 3:10 PM", who: "Wade Hollis" },
|
||||
{ text: "First contact made — homeowner confirmed damage.", time: "May 15, 2026, 2:00 PM", who: "Wade Hollis" },
|
||||
{ text: "Verified and pushed to New Leads.", time: "May 16, 2026, 7:55 PM", who: "Wade Hollis" },
|
||||
],
|
||||
},
|
||||
{ id: "LD-V-002", initials: "S", name: "Sandra Nguyen", address: "4817 Shady Brook Ln, Plano, TX 75093", phone: "(469) 551-7034", source: "Web Form", assignee: DARLENE, status: "verified", verification: "Verified", created: "May 17, 2026" },
|
||||
{ id: "LD-V-003", initials: "M", name: "Marcus Trevino", address: "1234 Oak Creek Blvd, Plano, TX 75075", phone: "(214) 837-4561", source: "Storm Canvass", assignee: WADE, status: "in_progress", verification: "Verifying Identity", created: "May 22, 2026" },
|
||||
{ id: "LD-V-004", initials: "B", name: "Brenda Kowalski", address: "890 Custer Rd, Plano, TX 75075", phone: "(972) 604-2817", source: "Referral", assignee: ROY, status: "in_progress", verification: "Reviewing Insurance", created: "May 20, 2026" },
|
||||
{ id: "LD-V-005", initials: "J", name: "James Whitaker", address: "3320 Parkhaven Dr, Plano, TX 75075", phone: "(469) 720-1188", source: "Door Knock", assignee: WADE, status: "in_progress", verification: "Confirming Ownership", created: "May 23, 2026" },
|
||||
{ id: "LD-V-006", initials: "P", name: "Priya Sharma", address: "5102 Mapleshade Ln, Plano, TX 75093", phone: "(972) 415-6620", source: "Web Form", assignee: DARLENE, status: "in_progress", verification: "Reviewing Insurance", created: "May 24, 2026" },
|
||||
{ id: "LD-V-007", initials: "C", name: "Carlos Mendez", address: "1470 Coit Rd, Plano, TX 75075", phone: "(214) 902-5533", source: "Storm Canvass", assignee: ROY, status: "in_progress", verification: "Confirming Damage", created: "May 25, 2026" },
|
||||
{ id: "LD-V-008", initials: "E", name: "Emily Carter", address: "2609 Rivercrest Dr, Plano, TX 75023", phone: "(469) 338-4471", source: "Door Knock", assignee: DARLENE, status: "assigned", verification: "Assigned", created: "May 26, 2026" },
|
||||
{ id: "LD-V-009", initials: "T", name: "Tyrone Jackson", address: "744 Legacy Dr, Plano, TX 75023", phone: "(972) 551-9042", source: "Referral", assignee: WADE, status: "assigned", verification: "Assigned", created: "May 26, 2026" },
|
||||
{ id: "LD-V-010", initials: "N", name: "Nicole Foster", address: "3901 Preston Meadow Dr, Plano, TX 75093", phone: "(214) 660-7719", source: "Web Form", assignee: ROY, status: "assigned", verification: "Assigned", created: "May 27, 2026" },
|
||||
{ id: "LD-V-011", initials: "A", name: "Aaron Blake", address: "1188 Alma Dr, Plano, TX 75075", phone: "(469) 471-3350", source: "Door Knock", assignee: null, status: "pending", verification: "Pending Review", created: "May 27, 2026" },
|
||||
{ id: "LD-V-012", initials: "G", name: "Grace Liu", address: "5540 Communications Pkwy, Plano, TX 75093", phone: "(972) 883-2201", source: "Web Form", assignee: null, status: "pending", verification: "Pending Review", created: "May 28, 2026" },
|
||||
{ id: "LD-V-013", initials: "D", name: "Derek Olsen", address: "902 Independence Pkwy, Plano, TX 75075", phone: "(214) 774-6690", source: "Call-In", assignee: null, status: "pending", verification: "Pending Review", created: "May 28, 2026" },
|
||||
{ id: "LD-V-014", initials: "M", name: "Monica Reyes", address: "3115 Rasor Blvd, Plano, TX 75093", phone: "(469) 205-8814", source: "Storm Canvass", assignee: null, status: "unverified", verification: "Unverified", created: "May 29, 2026" },
|
||||
{ id: "LD-V-015", initials: "S", name: "Sam Patterson", address: "677 Spring Creek Pkwy, Plano, TX 75023", phone: "(972) 330-1247", source: "Call-In", assignee: null, status: "unverified", verification: "Unverified", created: "May 29, 2026" },
|
||||
];
|
||||
|
||||
export const V_SOURCES = ["Door Knock", "Web Form", "Storm Canvass", "Referral", "Call-In"];
|
||||
export const V_ASSIGNEES = ["Wade Hollis", "Darlene Brooks", "Roy Schaefer"];
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Lead Verification — the verification desk.
|
||||
// · Stat tiles : Verified / In Progress / Assigned / Pending
|
||||
// / Unverified counts (click to filter)
|
||||
// · Toolbar : search + status / source / assignee filters
|
||||
// · Table : Lead ID · Customer · Phone · Source ·
|
||||
// Assigned To · Status · Verification · Created
|
||||
// · Actions (view / verify / ⋯ menu)
|
||||
// · Detail : view → popup with Contact, Assignment,
|
||||
// Verification Notes and an Activity timeline
|
||||
// Data comes from verify-data.ts (client-side mock).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Avatar, Btn, Icon, Modal, PageHead, Pill, useToast } from "./ui";
|
||||
import { V_LEADS, V_STATUS_META, V_SOURCES, V_ASSIGNEES, type VStatus, type VLead, type VActivity } from "./verify-data";
|
||||
|
||||
const STAT_ORDER: VStatus[] = ["verified", "in_progress", "assigned", "pending", "unverified"];
|
||||
const STAT_ICON: Record<VStatus, string> = {
|
||||
verified: "check-circle", in_progress: "refresh", assigned: "user", pending: "clock", unverified: "alert",
|
||||
};
|
||||
|
||||
/* ---- derive detail when the row doesn't carry it ---------- */
|
||||
function deriveEmail(l: VLead) {
|
||||
if (l.email) return l.email;
|
||||
const [first, ...rest] = l.name.toLowerCase().split(" ");
|
||||
return `${first}.${rest.join("")}@gmail.com`;
|
||||
}
|
||||
function deriveCreatedAt(l: VLead) { return l.createdAt ?? `${l.created}, 10:00 AM`; }
|
||||
function buildActivity(l: VLead): VActivity[] {
|
||||
if (l.activity) return l.activity;
|
||||
const who = l.assignee?.name ?? "System";
|
||||
const a: VActivity[] = [{ text: `Lead submitted via ${l.source.toLowerCase()} intake.`, time: deriveCreatedAt(l), who: "System" }];
|
||||
if (l.assignee) a.push({ text: `Assigned to ${l.assignee.name}.`, time: l.created, who: l.assignee.name });
|
||||
if (l.status === "in_progress") a.push({ text: `Verification in progress — ${l.verification.toLowerCase()}.`, time: l.created, who });
|
||||
if (l.status === "verified") a.push({ text: "Verified and pushed to New Leads.", time: l.verifiedAt ?? l.created, who });
|
||||
return a;
|
||||
}
|
||||
|
||||
type MenuState = { lead: VLead; x: number; y: number } | null;
|
||||
|
||||
export function Verify() {
|
||||
const toast = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [source, setSource] = useState("all");
|
||||
const [assignee, setAssignee] = useState("all");
|
||||
const [selected, setSelected] = useState<VLead | null>(null);
|
||||
const [menu, setMenu] = useState<MenuState>(null);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const m: Record<string, number> = {};
|
||||
for (const l of V_LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
|
||||
return m;
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return V_LEADS.filter((l) => {
|
||||
const matchQ = !q || `${l.name} ${l.id} ${l.phone} ${l.source} ${l.address}`.toLowerCase().includes(q);
|
||||
const matchStatus = status === "all" || l.status === status;
|
||||
const matchSource = source === "all" || l.source === source;
|
||||
const matchAssignee = assignee === "all" || l.assignee?.name === assignee;
|
||||
return matchQ && matchStatus && matchSource && matchAssignee;
|
||||
});
|
||||
}, [query, status, source, assignee]);
|
||||
|
||||
function act(l: VLead, title: string, desc: string, tone: "success" | "info" = "info") {
|
||||
setMenu(null);
|
||||
toast.push({ tone, title, desc });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view lv">
|
||||
<PageHead
|
||||
eyebrow="Sales"
|
||||
title="Lead Verification"
|
||||
subtitle="Identity & insurance checks before a lead becomes a working deal."
|
||||
icon="verify"
|
||||
actions={<Btn variant="outline" icon="refresh" onClick={() => toast.push({ tone: "info", title: "Queue refreshed", desc: "Verification queue is up to date." })}>Refresh</Btn>}
|
||||
/>
|
||||
|
||||
{/* ---- stat tiles ---- */}
|
||||
<div className="lv-stats">
|
||||
{STAT_ORDER.map((s) => {
|
||||
const meta = V_STATUS_META[s];
|
||||
return (
|
||||
<button key={s} className={`lv-stat tone-${meta.tone} ${status === s ? "active" : ""}`} onClick={() => setStatus(status === s ? "all" : s)}>
|
||||
<span className="lv-stat-ic"><Icon name={STAT_ICON[s]} size={16} /></span>
|
||||
<span className="lv-stat-val">{counts[s] ?? 0}</span>
|
||||
<span className="lv-stat-lbl">{meta.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ---- toolbar ---- */}
|
||||
<div className="lv-toolbar">
|
||||
<div className="lv-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search name, lead ID, phone, source…" aria-label="Search verification leads" />
|
||||
{query && <button className="lv-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||||
</div>
|
||||
<select className="ds-select lv-filter" value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Filter by status">
|
||||
<option value="all">All statuses</option>
|
||||
{STAT_ORDER.map((s) => <option key={s} value={s}>{V_STATUS_META[s].label}</option>)}
|
||||
</select>
|
||||
<select className="ds-select lv-filter" value={source} onChange={(e) => setSource(e.target.value)} aria-label="Filter by source">
|
||||
<option value="all">All sources</option>
|
||||
{V_SOURCES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="ds-select lv-filter" value={assignee} onChange={(e) => setAssignee(e.target.value)} aria-label="Filter by assignee">
|
||||
<option value="all">All assignees</option>
|
||||
{V_ASSIGNEES.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* ---- table ---- */}
|
||||
<div className="lv-tablewrap">
|
||||
<table className="lv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lead ID</th><th>Customer</th><th>Phone</th><th>Source</th>
|
||||
<th>Assigned To</th><th>Status</th><th>Verification</th><th>Created</th>
|
||||
<th className="lv-actions-h">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={9} className="lv-empty">No leads match your filters.</td></tr>
|
||||
) : rows.map((l) => {
|
||||
const meta = V_STATUS_META[l.status];
|
||||
return (
|
||||
<tr key={l.id}>
|
||||
<td><div className="lv-id">{l.id}</div><div className="lv-id-date">{l.created}</div></td>
|
||||
<td>
|
||||
<div className="lv-cust">
|
||||
<Avatar initials={l.initials} size={34} />
|
||||
<div className="lv-cust-body">
|
||||
<div className="lv-cust-name">{l.name}</div>
|
||||
<div className="lv-cust-addr">{l.address}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="lv-phone">{l.phone}</td>
|
||||
<td><span className="lv-source">{l.source}</span></td>
|
||||
<td>
|
||||
{l.assignee ? (
|
||||
<div className="lv-assignee">
|
||||
<Avatar initials={l.assignee.initials} size={26} gradient="linear-gradient(135deg,#4f8cff,#2c5cff)" />
|
||||
<span>{l.assignee.name}</span>
|
||||
</div>
|
||||
) : <span className="lv-unassigned">— Unassigned</span>}
|
||||
</td>
|
||||
<td><Pill tone={meta.tone}>{meta.label}</Pill></td>
|
||||
<td><span className={`lv-verif v-${l.status}`}><Icon name={STAT_ICON[l.status]} size={13} /> {l.verification}</span></td>
|
||||
<td className="lv-created">{l.created}</td>
|
||||
<td>
|
||||
<div className="lv-rowacts">
|
||||
<button className="lv-act" aria-label="View details" title="View details" onClick={() => setSelected(l)}><Icon name="eye" size={15} /></button>
|
||||
<button className="lv-act primary" aria-label="Verify" title="Verify lead" onClick={() => act(l, "Marked verified", `${l.name} moved to Verified.`, "success")}><Icon name="check-circle" size={15} /></button>
|
||||
<button className="lv-act" aria-label="More actions" title="More actions" onClick={(e) => setMenu(menu?.lead.id === l.id ? null : { lead: l, x: e.clientX, y: e.clientY })}><Icon name="dots" size={15} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="lv-count">{rows.length} of {V_LEADS.length} leads</div>
|
||||
|
||||
<ActionsMenu menu={menu} onClose={() => setMenu(null)} onAct={act} onView={(l) => { setSelected(l); setMenu(null); }} />
|
||||
<VerifyDetail lead={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Row actions dropdown (portalled, fixed-positioned) */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function ActionsMenu({ menu, onClose, onAct, onView }: {
|
||||
menu: MenuState; onClose: () => void;
|
||||
onAct: (l: VLead, title: string, desc: string, tone?: "success" | "info") => void;
|
||||
onView: (l: VLead) => void;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [menu, onClose]);
|
||||
if (!menu || !mounted) return null;
|
||||
|
||||
const l = menu.lead;
|
||||
const left = Math.max(8, menu.x - 196);
|
||||
const top = Math.min(menu.y + 8, window.innerHeight - 260);
|
||||
|
||||
const items = [
|
||||
{ icon: "eye", label: "View Details", run: () => onView(l) },
|
||||
{ icon: "check-circle", label: "Verify Lead", run: () => onAct(l, "Verified", `${l.name} marked as verified.`, "success") },
|
||||
{ icon: "alert", label: "Mark Unverified", run: () => onAct(l, "Marked unverified", `${l.name} moved to Unverified.`) },
|
||||
{ icon: "user", label: "Change Assignee", run: () => onAct(l, "Change assignee", `Pick a new rep for ${l.name}.`) },
|
||||
{ icon: "refresh", label: "Reassign (In Progress)", run: () => onAct(l, "Reassigned", `${l.name} set to In Progress.`) },
|
||||
{ icon: "clock", label: "Move to Pending", run: () => onAct(l, "Moved to pending", `${l.name} is now Pending review.`) },
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="lv-menu-scrim" onClick={onClose} />
|
||||
<div className="lv-menu" style={{ left, top }} role="menu">
|
||||
{items.map((it) => (
|
||||
<button key={it.label} className="lv-menu-item" role="menuitem" onClick={it.run}>
|
||||
<Icon name={it.icon} size={14} /> {it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Verification detail popup */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function VerifyDetail({ lead, onClose }: { lead: VLead | null; onClose: () => void }) {
|
||||
if (!lead) return null;
|
||||
const meta = V_STATUS_META[lead.status];
|
||||
const activity = buildActivity(lead);
|
||||
|
||||
return (
|
||||
<Modal open={!!lead} onClose={onClose} size="lg" title={lead.name} subtitle={`${lead.id} · ${lead.verification}`} icon="verify"
|
||||
footer={<>
|
||||
<Btn variant="ghost" icon="phone">Call</Btn>
|
||||
<Btn icon="check-circle">Verify Lead</Btn>
|
||||
</>}
|
||||
>
|
||||
<div className="lv-detail">
|
||||
<div className="lv-d-identity">
|
||||
<Avatar initials={lead.initials} size={50} />
|
||||
<div>
|
||||
<div className="lv-d-name">{lead.name}</div>
|
||||
<div className="lv-d-pills">
|
||||
<Pill tone={meta.tone}>{meta.label}</Pill>
|
||||
<span className={`lv-verif v-${lead.status}`}><Icon name={STAT_ICON[lead.status]} size={13} /> {lead.verification}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lv-d-grid">
|
||||
<div className="lv-d-section">
|
||||
<div className="lv-d-head"><Icon name="user" size={15} /> Contact</div>
|
||||
<Row label="Phone" value={lead.phone} />
|
||||
<Row label="Email" value={deriveEmail(lead)} />
|
||||
<Row label="Address" value={lead.address} />
|
||||
<Row label="Source" value={lead.source} />
|
||||
</div>
|
||||
<div className="lv-d-section">
|
||||
<div className="lv-d-head"><Icon name="team" size={15} /> Assignment</div>
|
||||
<Row label="Assigned To" value={lead.assignee?.name ?? "Unassigned"} />
|
||||
<Row label="Created" value={deriveCreatedAt(lead)} />
|
||||
{lead.verifiedAt && <Row label="Verified At" value={lead.verifiedAt} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lead.notes && (
|
||||
<div className="lv-d-notes">
|
||||
<div className="lv-d-head"><Icon name="edit" size={15} /> Verification Notes</div>
|
||||
<p>{lead.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="lv-d-activity">
|
||||
<div className="lv-d-head"><Icon name="clock" size={15} /> Activity</div>
|
||||
<ul className="lv-timeline">
|
||||
{activity.map((a, i) => (
|
||||
<li key={i} className="lv-tl-item">
|
||||
<span className="lv-tl-dot" />
|
||||
<div className="lv-tl-body">
|
||||
<div className="lv-tl-text">{a.text}</div>
|
||||
<div className="lv-tl-meta">{a.time} · {a.who}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="lv-d-row">
|
||||
<span className="lv-d-k">{label}</span>
|
||||
<span className="lv-d-v">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
// When no socket is available (token failed / demo), it degrades to a 4s history poll.
|
||||
|
||||
import type {
|
||||
Attachment,
|
||||
ChannelSummary,
|
||||
ChannelVisibility,
|
||||
Conversation,
|
||||
@@ -32,7 +33,7 @@ interface ConversationDTO {
|
||||
threadId: string; subject: string | null; membership: Membership | null;
|
||||
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
|
||||
}
|
||||
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
|
||||
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null; attachment?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null }
|
||||
|
||||
const POLL_MS = 4000;
|
||||
const REACTION = "reaction";
|
||||
@@ -44,6 +45,8 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
|
||||
private readonly polls = new Map<string, Poll>();
|
||||
private readonly joined = new Set<string>();
|
||||
/** Cross-thread activity listeners (live unread + in-app notifications). */
|
||||
private readonly activity = new Set<(e: { threadId: string; message: Message }) => void>();
|
||||
/** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */
|
||||
private readonly reactions = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
@@ -58,7 +61,10 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
if (socket) {
|
||||
socket.on("message", (m) => {
|
||||
this.ingestReactions(m);
|
||||
this.emit(m.threadId, { kind: "message", message: this.fromKernel(m) });
|
||||
void this.toKernelMessage(m).then((message) => {
|
||||
this.emit(m.threadId, { kind: "message", message });
|
||||
for (const cb of this.activity) cb({ threadId: m.threadId, message });
|
||||
});
|
||||
});
|
||||
socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId }));
|
||||
// Receipts carry no threadId → fan to all open threads; the UI filters by messageId.
|
||||
@@ -78,6 +84,37 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
return this.me;
|
||||
}
|
||||
|
||||
/** Report the foregrounded thread to IIOS presence (suppresses push for what you're viewing). */
|
||||
setFocus(threadId: string | null): void {
|
||||
this.socket?.focus(threadId);
|
||||
}
|
||||
|
||||
/** Fire `cb` for every incoming message across ALL the caller's threads (live unread + toasts). */
|
||||
subscribeActivity(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe {
|
||||
this.activity.add(cb);
|
||||
void this.joinAllThreads();
|
||||
return () => {
|
||||
this.activity.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
/** Join every thread the user belongs to so their messages arrive over the socket, not just the
|
||||
* open one. Idempotent (the `joined` set guards re-joins). */
|
||||
private async joinAllThreads(): Promise<void> {
|
||||
if (!this.socket) return;
|
||||
try {
|
||||
const convs = await this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {});
|
||||
for (const c of convs) {
|
||||
if (!this.joined.has(c.threadId)) {
|
||||
this.joined.add(c.threadId);
|
||||
void this.socket.openThread(c.threadId).catch(() => this.joined.delete(c.threadId));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort — activity just won't cover un-joined threads */
|
||||
}
|
||||
}
|
||||
|
||||
async listConversations(): Promise<Conversation[]> {
|
||||
const [convs, names] = await Promise.all([
|
||||
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
|
||||
@@ -99,28 +136,43 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
if (this.socket) {
|
||||
const res = await this.socket.openThread(threadId); // joins so live events flow
|
||||
this.joined.add(threadId);
|
||||
return res.history.map((m) => {
|
||||
this.ingestReactions(m);
|
||||
return this.fromKernel(m);
|
||||
});
|
||||
return Promise.all(
|
||||
res.history.map((m) => {
|
||||
this.ingestReactions(m);
|
||||
return this.toKernelMessage(m);
|
||||
}),
|
||||
);
|
||||
}
|
||||
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
|
||||
return msgs.map((m) => this.fromDto(m));
|
||||
return Promise.all(msgs.map((m) => this.toDtoMessage(m)));
|
||||
}
|
||||
|
||||
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
|
||||
const att = opts?.attachment;
|
||||
if (this.socket) {
|
||||
const sendOpts = {
|
||||
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
|
||||
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
|
||||
...(att?.contentRef ? { attachment: { contentRef: att.contentRef, mimeType: att.mime, sizeBytes: att.sizeBytes ?? 0 } } : {}),
|
||||
};
|
||||
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
|
||||
return this.fromKernel(m);
|
||||
const msg = this.fromKernel(m);
|
||||
// Reuse the staged attachment (already carries a display URL from upload) for instant render.
|
||||
return att ? { ...msg, attachment: att } : msg;
|
||||
}
|
||||
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", { threadId, content });
|
||||
const msg = this.fromDto(m);
|
||||
this.polls.get(threadId)?.seen.add(msg.id);
|
||||
return msg;
|
||||
return att ? { ...msg, attachment: att } : msg;
|
||||
}
|
||||
|
||||
async upload(file: File): Promise<Attachment> {
|
||||
const mime = file.type || "application/octet-stream";
|
||||
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
|
||||
const res = await fetch(uploadUrl, { method: "PUT", body: file });
|
||||
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||
const url = await this.downloadUrl(objectKey, mime);
|
||||
return { url, mime, name: file.name, contentRef: objectKey, sizeBytes: file.size };
|
||||
}
|
||||
|
||||
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
|
||||
@@ -191,9 +243,21 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
await this.sdk.command("crm.messenger.channel.leave", { threadId });
|
||||
}
|
||||
|
||||
async addMember(threadId: string, userId: string): Promise<void> {
|
||||
await this.sdk.command("crm.messenger.participant.add", { threadId, userId });
|
||||
}
|
||||
|
||||
async removeMember(threadId: string, userId: string): Promise<void> {
|
||||
await this.sdk.command("crm.messenger.participant.remove", { threadId, userId });
|
||||
}
|
||||
|
||||
async renameConversation(threadId: string, subject: string): Promise<void> {
|
||||
await this.sdk.command("crm.messenger.group.rename", { threadId, subject });
|
||||
}
|
||||
|
||||
async listMembers(threadId: string): Promise<Person[]> {
|
||||
const rows = await this.sdk.query<Array<{ userId: string; displayName: string; role: string }>>("crm.messenger.members", { threadId });
|
||||
return rows.map((r) => ({ id: r.userId, name: r.displayName, kind: "staff" as const }));
|
||||
return rows.map((r) => ({ id: r.userId, name: r.displayName, kind: r.role === "CUSTOMER" ? "customer" : "staff" }));
|
||||
}
|
||||
|
||||
// ── polling fallback (no socket) ───────────────────────────────
|
||||
@@ -265,6 +329,31 @@ export class CrmMessagingAdapter implements MessagingAdapter {
|
||||
return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt };
|
||||
}
|
||||
|
||||
// ── attachments ────────────────────────────────────────────────
|
||||
/** A short-lived signed URL to display/download a stored object. */
|
||||
private async downloadUrl(contentRef: string, mime?: string): Promise<string> {
|
||||
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", { contentRef, ...(mime ? { mime } : {}) });
|
||||
return url;
|
||||
}
|
||||
|
||||
private async resolveAttachment(a: { contentRef: string; mimeType: string; sizeBytes: number; filename?: string | null } | null | undefined): Promise<Attachment | undefined> {
|
||||
if (!a?.contentRef) return undefined;
|
||||
const url = await this.downloadUrl(a.contentRef, a.mimeType);
|
||||
return { url, mime: a.mimeType, name: a.filename ?? "attachment", contentRef: a.contentRef, sizeBytes: a.sizeBytes };
|
||||
}
|
||||
|
||||
private async toKernelMessage(m: KernelMessage): Promise<Message> {
|
||||
const base = this.fromKernel(m);
|
||||
const att = await this.resolveAttachment(m.attachment ?? null);
|
||||
return att ? { ...base, attachment: att } : base;
|
||||
}
|
||||
|
||||
private async toDtoMessage(m: MessageDTO): Promise<Message> {
|
||||
const base = this.fromDto(m);
|
||||
const att = await this.resolveAttachment(m.attachment ?? null);
|
||||
return att ? { ...base, attachment: att } : base;
|
||||
}
|
||||
|
||||
// ── reaction state ─────────────────────────────────────────────
|
||||
private ingestReactions(m: KernelMessage): void {
|
||||
for (const a of m.annotations ?? []) {
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The CRM's AIProvider for the Smart Gallery.
|
||||
*
|
||||
* PROVENANCE: a port of the SDK demo's `createDemoAIProvider()` plus its
|
||||
* clip/face/ocr/tensorflow/runpod-yolo providers and `imageEncode.ts` helpers
|
||||
* (advance-photo-gallery-web-sdk/apps/web/src/lib/ai/*), collapsed into one
|
||||
* module and retargeted from `/api/ai/*` to `/api/gallery/ai/*`.
|
||||
*
|
||||
* Capability split:
|
||||
* - object detection: TensorFlow.js COCO-SSD, fully in-browser (no key), or the
|
||||
* RunPod YOLO classifier via /api/gallery/ai/classify when
|
||||
* NEXT_PUBLIC_APG_RUNPOD_DETECT=true (COCO-SSD is the automatic fallback)
|
||||
* - face detection + recognition: face-api.js in-browser → clustered into People
|
||||
* - OCR: tesseract.js in-browser → searchable text + the Documents album
|
||||
* - semantic search: CLIP via transformers.js in-browser
|
||||
* - background removal: @imgly in-browser WASM, or the RunPod U²-Net endpoint
|
||||
* when NEXT_PUBLIC_APG_RUNPOD_BG=true (in-browser is the fallback)
|
||||
* - other generative edits / transcription / denoise / tilt: proxied through the
|
||||
* server routes so the RunPod key never reaches the browser
|
||||
*
|
||||
* EVERY heavy model is behind `await import(...)` so none of it lands in the
|
||||
* initial bundle, and every capability degrades to []/''/null with a
|
||||
* console.warn rather than throwing — a failed model must never break the
|
||||
* gallery UI.
|
||||
*
|
||||
* The in-browser models fetch weights from public CDNs (jsdelivr, huggingface,
|
||||
* storage.googleapis.com, staticimgly.com). See docs/SMART_GALLERY.md for the
|
||||
* list that would need CSP allow-listing.
|
||||
*/
|
||||
|
||||
import type { AIProvider, GenerativeEditOp, MediaItem } from "@photo-gallery/sdk";
|
||||
|
||||
// Derived from the provider interface so we import only the three public types.
|
||||
type DetectedObject = Awaited<ReturnType<NonNullable<AIProvider["detectObjects"]>>>[number];
|
||||
type DetectedFace = Awaited<ReturnType<NonNullable<AIProvider["detectFaces"]>>>[number];
|
||||
type ImageSource = ImageBitmap | HTMLImageElement;
|
||||
|
||||
const API = "/api/gallery/ai";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// imageEncode helpers (ported from apps/web/src/lib/ai/imageEncode.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EncodedImage {
|
||||
/** base64 JPEG (no data: prefix). */
|
||||
data: string;
|
||||
mimeType: string;
|
||||
/** Actual pixel dims of the encoded image (after downscale). */
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Draw an image to a downscaled canvas and return base64 JPEG + its dims. */
|
||||
export function imageToBase64(image: ImageSource, maxDim: number): EncodedImage {
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||
const scale = Math.min(1, maxDim / Math.max(w, h));
|
||||
const cw = Math.max(1, Math.round(w * scale));
|
||||
const ch = Math.max(1, Math.round(h * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas not supported.");
|
||||
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
|
||||
const dataUrl = canvas.toDataURL("image/jpeg", 0.9);
|
||||
return { data: dataUrl.split(",")[1] ?? "", mimeType: "image/jpeg", width: cw, height: ch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rasterize a mask (ImageData, white = region to regenerate) to a PNG base64
|
||||
* scaled to targetW×targetH so it matches the encoded image exactly — SD 3.5
|
||||
* requires image and mask to be identical pixel sizes.
|
||||
*/
|
||||
export function maskToBase64(mask: ImageData, targetW: number, targetH: number): string {
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = mask.width;
|
||||
tmp.height = mask.height;
|
||||
const tctx = tmp.getContext("2d");
|
||||
if (!tctx) throw new Error("Canvas not supported.");
|
||||
tctx.putImageData(mask, 0, 0);
|
||||
|
||||
const out = document.createElement("canvas");
|
||||
out.width = targetW;
|
||||
out.height = targetH;
|
||||
const octx = out.getContext("2d");
|
||||
if (!octx) throw new Error("Canvas not supported.");
|
||||
// Nearest-neighbour, not bilinear — keep the mask strictly binary so SD gets
|
||||
// crisp white(regenerate)/black(keep) edges instead of an anti-aliased grey halo.
|
||||
octx.imageSmoothingEnabled = false;
|
||||
octx.drawImage(tmp, 0, 0, targetW, targetH);
|
||||
return out.toDataURL("image/png").split(",")[1] ?? "";
|
||||
}
|
||||
|
||||
export function base64ToBlob(base64: string, mime: string): Blob {
|
||||
const bin = atob(base64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad an image with a neutral border for outpaint and return {imageBase64, maskBase64}
|
||||
* as base64 PNG — the border is WHITE in the mask (regenerate), the original image
|
||||
* area BLACK (keep). Capped at 1280px on the long side.
|
||||
*/
|
||||
export function padForOutpaint(
|
||||
image: ImageSource,
|
||||
factor: number,
|
||||
): { imageBase64: string; maskBase64: string } {
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||
const f = Math.max(1.1, Math.min(2, factor));
|
||||
const maxDim = 1280;
|
||||
let pw = Math.round(w * f);
|
||||
let ph = Math.round(h * f);
|
||||
const scale = Math.min(1, maxDim / Math.max(pw, ph));
|
||||
pw = Math.max(16, Math.round(pw * scale));
|
||||
ph = Math.max(16, Math.round(ph * scale));
|
||||
const iw = Math.max(1, Math.round(w * scale));
|
||||
const ih = Math.max(1, Math.round(h * scale));
|
||||
const ox = Math.floor((pw - iw) / 2);
|
||||
const oy = Math.floor((ph - ih) / 2);
|
||||
|
||||
const imgCanvas = document.createElement("canvas");
|
||||
imgCanvas.width = pw;
|
||||
imgCanvas.height = ph;
|
||||
const ictx = imgCanvas.getContext("2d");
|
||||
if (!ictx) throw new Error("Canvas not supported.");
|
||||
// Fill the new border with a blurred, stretched copy of the photo so the model
|
||||
// has real color/context to continue from — flat gray gives it nothing.
|
||||
ictx.filter = "blur(28px)";
|
||||
ictx.drawImage(image as CanvasImageSource, 0, 0, pw, ph);
|
||||
ictx.filter = "none";
|
||||
ictx.drawImage(image as CanvasImageSource, ox, oy, iw, ih);
|
||||
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = pw;
|
||||
maskCanvas.height = ph;
|
||||
const mctx = maskCanvas.getContext("2d");
|
||||
if (!mctx) throw new Error("Canvas not supported.");
|
||||
mctx.fillStyle = "#ffffff";
|
||||
mctx.fillRect(0, 0, pw, ph);
|
||||
mctx.fillStyle = "#000000";
|
||||
mctx.fillRect(ox, oy, iw, ih);
|
||||
|
||||
return {
|
||||
imageBase64: imgCanvas.toDataURL("image/png").split(",")[1] ?? "",
|
||||
maskBase64: maskCanvas.toDataURL("image/png").split(",")[1] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Draw an image to a canvas (downscaled) and return a JPEG Blob. */
|
||||
function canvasBlob(image: ImageSource, maxDim: number): Promise<Blob> {
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||
const scale = Math.min(1, maxDim / Math.max(w, h));
|
||||
const cw = Math.max(1, Math.round(w * scale));
|
||||
const ch = Math.max(1, Math.round(h * scale));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return Promise.reject(new Error("Canvas not supported."));
|
||||
ctx.drawImage(image as CanvasImageSource, 0, 0, cw, ch);
|
||||
return new Promise((resolve, reject) =>
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92),
|
||||
);
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
return n < 0 ? 0 : n > 1 ? 1 : n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object detection — TensorFlow.js COCO-SSD, in-browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CocoPrediction {
|
||||
bbox: [number, number, number, number];
|
||||
class: string;
|
||||
score: number;
|
||||
}
|
||||
interface CocoModel {
|
||||
detect(
|
||||
img: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement,
|
||||
maxNumBoxes?: number,
|
||||
minScore?: number,
|
||||
): Promise<CocoPrediction[]>;
|
||||
}
|
||||
|
||||
let cocoPromise: Promise<CocoModel | null> | null = null;
|
||||
|
||||
/** Load tfjs + COCO-SSD exactly once; resolves to null if anything fails. */
|
||||
function ensureCoco(): Promise<CocoModel | null> {
|
||||
cocoPromise ??= (async () => {
|
||||
try {
|
||||
const tf = await import("@tensorflow/tfjs");
|
||||
try {
|
||||
await tf.setBackend("webgl");
|
||||
} catch {
|
||||
// Fall back to the default backend if WebGL is unavailable.
|
||||
}
|
||||
await tf.ready();
|
||||
const cocoSsd = await import("@tensorflow-models/coco-ssd");
|
||||
return (await cocoSsd.load({ base: "lite_mobilenet_v2" })) as unknown as CocoModel;
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] COCO-SSD load failed; object detection disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return cocoPromise;
|
||||
}
|
||||
|
||||
async function detectObjectsInBrowser(
|
||||
item: MediaItem,
|
||||
image: ImageSource,
|
||||
): Promise<DetectedObject[]> {
|
||||
const model = await ensureCoco();
|
||||
if (!model) return [];
|
||||
try {
|
||||
const el = image as HTMLImageElement;
|
||||
const w = el.naturalWidth || el.width || item.width || 1;
|
||||
const h = el.naturalHeight || el.height || item.height || 1;
|
||||
const predictions = await model.detect(el, 20, 0.4);
|
||||
return predictions.map((p) => ({
|
||||
label: p.class,
|
||||
confidence: p.score,
|
||||
box: { x: p.bbox[0] / w, y: p.bbox[1] / h, width: p.bbox[2] / w, height: p.bbox[3] / h },
|
||||
})) as DetectedObject[];
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] object detection failed.", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side YOLO detection via /api/gallery/ai/classify, with the in-browser
|
||||
* COCO-SSD as the automatic fallback so detection never hard-fails.
|
||||
*/
|
||||
async function detectObjectsViaRunpod(
|
||||
item: MediaItem,
|
||||
image: ImageSource,
|
||||
): Promise<DetectedObject[]> {
|
||||
try {
|
||||
const { data, mimeType, width, height } = imageToBase64(image, 1280);
|
||||
const res = await fetch(`${API}/classify`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ imageBase64: data, mimeType, width, height }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`classify failed (${res.status})`);
|
||||
const { objects } = (await res.json()) as { objects?: DetectedObject[] };
|
||||
if (Array.isArray(objects)) return objects;
|
||||
throw new Error("classify returned no objects");
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] RunPod detection failed; falling back to COCO-SSD.", err);
|
||||
return detectObjectsInBrowser(item, image);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Faces — @vladmandic/face-api, in-browser (128-D descriptors → People)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FACE_MODEL_URL = "https://cdn.jsdelivr.net/npm/@vladmandic/face-api@1.7.15/model";
|
||||
|
||||
type FaceApi = typeof import("@vladmandic/face-api");
|
||||
|
||||
let facePromise: Promise<FaceApi | null> | null = null;
|
||||
|
||||
function ensureFaceModels(): Promise<FaceApi | null> {
|
||||
facePromise ??= (async () => {
|
||||
try {
|
||||
const faceapi = await import("@vladmandic/face-api");
|
||||
// The bundled tf re-export is typed narrowly; backend control lives on the
|
||||
// runtime object. Prefer WebGL (no eval; CSP-friendly), fall back gracefully.
|
||||
const tf = faceapi.tf as unknown as {
|
||||
setBackend: (b: string) => Promise<boolean>;
|
||||
ready: () => Promise<void>;
|
||||
};
|
||||
try {
|
||||
await tf.setBackend("webgl");
|
||||
} catch {
|
||||
/* keep default backend */
|
||||
}
|
||||
await tf.ready();
|
||||
await Promise.all([
|
||||
faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URL),
|
||||
faceapi.nets.faceLandmark68Net.loadFromUri(FACE_MODEL_URL),
|
||||
faceapi.nets.faceRecognitionNet.loadFromUri(FACE_MODEL_URL),
|
||||
]);
|
||||
return faceapi;
|
||||
} catch (err) {
|
||||
// Degrade gracefully — People simply stays empty if models can't load.
|
||||
console.warn("[gallery-ai] face model load failed; face clustering disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return facePromise;
|
||||
}
|
||||
|
||||
let faceWarned = false;
|
||||
|
||||
async function detectFaces(item: MediaItem, image: ImageSource): Promise<DetectedFace[]> {
|
||||
const faceapi = await ensureFaceModels();
|
||||
if (!faceapi) return [];
|
||||
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width || 1;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height || 1;
|
||||
|
||||
type TNetInput = Parameters<FaceApi["detectAllFaces"]>[0];
|
||||
|
||||
let results;
|
||||
try {
|
||||
results = await faceapi
|
||||
.detectAllFaces(
|
||||
image as unknown as TNetInput,
|
||||
new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.5 }),
|
||||
)
|
||||
.withFaceLandmarks()
|
||||
.withFaceDescriptors();
|
||||
} catch (err) {
|
||||
if (!faceWarned) {
|
||||
faceWarned = true;
|
||||
console.warn("[gallery-ai] face detection failed on", item.name, err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
return results.map((r) => {
|
||||
const b = r.detection.box;
|
||||
return {
|
||||
confidence: r.detection.score,
|
||||
box: {
|
||||
x: clamp01(b.x / w),
|
||||
y: clamp01(b.y / h),
|
||||
width: clamp01(b.width / w),
|
||||
height: clamp01(b.height / h),
|
||||
},
|
||||
embedding: Array.from(r.descriptor),
|
||||
};
|
||||
}) as DetectedFace[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OCR — tesseract.js, in-browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Must equal the EXACT tesseract.js version in package.json (pinned, no caret)
|
||||
// so the worker CDN URL can never drift from the installed main-thread code.
|
||||
const TESSERACT_VERSION = "5.1.1";
|
||||
const WORKER_PATH = `https://cdn.jsdelivr.net/npm/tesseract.js@${TESSERACT_VERSION}/dist/worker.min.js`;
|
||||
const CORE_PATH = "https://cdn.jsdelivr.net/npm/tesseract.js-core@5";
|
||||
// jsDelivr's GitHub mirror of naptha/tessdata (same files as projectnaptha.com),
|
||||
// so every asset comes from ONE host that a CSP can allow-list.
|
||||
const LANG_PATH = "https://cdn.jsdelivr.net/gh/naptha/tessdata@gh-pages/4.0.0";
|
||||
|
||||
interface OcrWord {
|
||||
text?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
interface OcrData {
|
||||
text?: string;
|
||||
confidence?: number;
|
||||
words?: OcrWord[];
|
||||
blocks?: Array<{ paragraphs?: Array<{ lines?: Array<{ words?: OcrWord[] }> }> }> | null;
|
||||
}
|
||||
|
||||
type TesseractWorker = import("tesseract.js").Worker;
|
||||
|
||||
let ocrWorkerPromise: Promise<TesseractWorker | null> | null = null;
|
||||
|
||||
function ensureOcrWorker(): Promise<TesseractWorker | null> {
|
||||
ocrWorkerPromise ??= (async () => {
|
||||
try {
|
||||
const { createWorker } = await import("tesseract.js");
|
||||
// v5: createWorker(langs, oem, options) already loads + initializes the
|
||||
// language internally — do NOT call the removed v4 worker.load().
|
||||
return await createWorker("eng", 1, {
|
||||
workerPath: WORKER_PATH,
|
||||
corePath: CORE_PATH,
|
||||
langPath: LANG_PATH,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] tesseract worker init failed; OCR disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return ocrWorkerPromise;
|
||||
}
|
||||
|
||||
const WORD_CONFIDENCE = 70; // a word tesseract is actually sure about
|
||||
const MIN_WORDS = 4; // need several confident words to call it a document
|
||||
const MIN_CHARS = 10;
|
||||
|
||||
function collectWords(data: OcrData): OcrWord[] {
|
||||
if (Array.isArray(data.words) && data.words.length) return data.words;
|
||||
const out: OcrWord[] = [];
|
||||
for (const b of data.blocks ?? [])
|
||||
for (const p of b.paragraphs ?? [])
|
||||
for (const l of p.lines ?? []) for (const w of l.words ?? []) out.push(w);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return real text or '' (not a document). tesseract hallucinates low-confidence
|
||||
* gibberish for photos with no text, so we keep only high-confidence, word-shaped
|
||||
* tokens and require several of them.
|
||||
*/
|
||||
function meaningfulText(data: OcrData): string {
|
||||
const words = collectWords(data);
|
||||
if (words.length > 0) {
|
||||
const good = words.filter(
|
||||
(w) => (w.confidence ?? 0) >= WORD_CONFIDENCE && /[A-Za-z0-9]{2,}/.test(w.text ?? ""),
|
||||
);
|
||||
const text = good
|
||||
.map((w) => (w.text ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
return good.length >= MIN_WORDS && text.length >= MIN_CHARS ? text : "";
|
||||
}
|
||||
// Fallback: overall confidence + count of word-shaped tokens.
|
||||
const raw = (data.text ?? "").trim();
|
||||
const conf = typeof data.confidence === "number" ? data.confidence : 0;
|
||||
const realWords = raw.match(/[A-Za-z]{3,}/g) ?? [];
|
||||
return conf >= 72 && realWords.length >= 6 ? raw : "";
|
||||
}
|
||||
|
||||
async function ocr(_item: MediaItem, image: ImageSource): Promise<string> {
|
||||
const worker = await ensureOcrWorker();
|
||||
if (!worker) return "";
|
||||
try {
|
||||
// Request the block hierarchy so per-word confidence is available.
|
||||
const { data } = (await worker.recognize(
|
||||
image as unknown as HTMLImageElement,
|
||||
{},
|
||||
{ text: true, blocks: true },
|
||||
)) as { data: OcrData };
|
||||
return meaningfulText(data);
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] OCR failed.", err);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic search — CLIP via transformers.js (ONNX-WASM), in-browser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CLIP_MODEL_ID = "Xenova/clip-vit-base-patch16";
|
||||
|
||||
type Transformers = typeof import("@huggingface/transformers");
|
||||
|
||||
let transformersMod: Transformers | null = null;
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any -- transformers.js pipelines are untyped */
|
||||
let clipVisionPromise: Promise<{ processor: any; model: any } | null> | null = null;
|
||||
let clipTextPromise: Promise<{ tokenizer: any; model: any } | null> | null = null;
|
||||
|
||||
async function loadTransformers(): Promise<Transformers> {
|
||||
if (!transformersMod) {
|
||||
transformersMod = await import("@huggingface/transformers");
|
||||
// Remote-only (models from the HF CDN); rely on browser cache between sessions.
|
||||
transformersMod.env.allowLocalModels = false;
|
||||
}
|
||||
return transformersMod;
|
||||
}
|
||||
|
||||
function ensureClipVision() {
|
||||
clipVisionPromise ??= (async () => {
|
||||
try {
|
||||
const tf = await loadTransformers();
|
||||
const [processor, model] = await Promise.all([
|
||||
tf.AutoProcessor.from_pretrained(CLIP_MODEL_ID),
|
||||
tf.CLIPVisionModelWithProjection.from_pretrained(CLIP_MODEL_ID),
|
||||
]);
|
||||
return { processor, model };
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] CLIP vision load failed; semantic search disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return clipVisionPromise;
|
||||
}
|
||||
|
||||
function ensureClipText() {
|
||||
clipTextPromise ??= (async () => {
|
||||
try {
|
||||
const tf = await loadTransformers();
|
||||
const [tokenizer, model] = await Promise.all([
|
||||
tf.AutoTokenizer.from_pretrained(CLIP_MODEL_ID),
|
||||
tf.CLIPTextModelWithProjection.from_pretrained(CLIP_MODEL_ID),
|
||||
]);
|
||||
return { tokenizer, model };
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] CLIP text load failed; semantic search disabled.", err);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return clipTextPromise;
|
||||
}
|
||||
|
||||
/** Draw an image onto a canvas (downscaled) for the CLIP image processor. */
|
||||
function toCanvas(image: ImageSource, maxDim = 384): HTMLCanvasElement {
|
||||
const w = (image as HTMLImageElement).naturalWidth || (image as ImageBitmap).width;
|
||||
const h = (image as HTMLImageElement).naturalHeight || (image as ImageBitmap).height;
|
||||
const scale = Math.min(1, maxDim / Math.max(w, h));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round(w * scale));
|
||||
canvas.height = Math.max(1, Math.round(h * scale));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas not supported.");
|
||||
ctx.drawImage(image as CanvasImageSource, 0, 0, canvas.width, canvas.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function tensorToArray(t: any): number[] {
|
||||
const data: Float32Array = t?.data ?? t;
|
||||
return Array.from(data as ArrayLike<number>);
|
||||
}
|
||||
|
||||
async function embedImage(_item: MediaItem, image: ImageSource): Promise<number[]> {
|
||||
const v = await ensureClipVision();
|
||||
if (!v) return [];
|
||||
try {
|
||||
const tf = await loadTransformers();
|
||||
const canvas = toCanvas(image);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return [];
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const raw = new tf.RawImage(imageData.data, canvas.width, canvas.height, 4).rgb();
|
||||
const inputs = await v.processor(raw);
|
||||
const out = await v.model(inputs);
|
||||
return tensorToArray(out.image_embeds);
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] embedImage failed.", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function embedText(query: string): Promise<number[]> {
|
||||
const t = await ensureClipText();
|
||||
if (!t) return [];
|
||||
try {
|
||||
const inputs = t.tokenizer([query], { padding: true, truncation: true });
|
||||
const out = await t.model(inputs);
|
||||
return tensorToArray(out.text_embeds);
|
||||
} catch (err) {
|
||||
console.warn("[gallery-ai] embedText failed.", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `true` only for the literal string "true", matching the SDK demo's semantics. */
|
||||
function flag(v: string | undefined): boolean {
|
||||
return v === "true";
|
||||
}
|
||||
|
||||
export function createCrmAIProvider(): AIProvider {
|
||||
// NEXT_PUBLIC_* are inlined at build time, so these must be read as full
|
||||
// static member expressions — do NOT refactor to dynamic indexing.
|
||||
const useRunpodDetect = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_DETECT);
|
||||
const useRunpodBg = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_BG);
|
||||
const useRunpodTilt = flag(process.env.NEXT_PUBLIC_APG_RUNPOD_TILT);
|
||||
|
||||
return {
|
||||
name: "crm-ai (coco-ssd/yolo + face-api + tesseract + clip + runpod-edit)",
|
||||
|
||||
detectObjects: useRunpodDetect ? detectObjectsViaRunpod : detectObjectsInBrowser,
|
||||
detectFaces,
|
||||
ocr,
|
||||
embedImage,
|
||||
embedText,
|
||||
|
||||
async generativeEdit(item: MediaItem, image: ImageSource, op: GenerativeEditOp) {
|
||||
// Remove Background runs fully in-browser (no key) via @imgly — works even
|
||||
// with no backend. Other ops go through the /api/gallery/ai/edit route.
|
||||
if (op.type === "remove-background") {
|
||||
// Prefer the RunPod U²-Net endpoint when enabled; fall back to in-browser
|
||||
// @imgly if it's off or the request fails, so this always produces a result.
|
||||
if (useRunpodBg) {
|
||||
try {
|
||||
const { data, mimeType } = imageToBase64(image, 1600);
|
||||
const res = await fetch(`${API}/edit`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imageBase64: data,
|
||||
mimeType,
|
||||
op: { type: "remove-background" },
|
||||
params: {},
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { imageBase64: out, mimeType: outMime } = (await res.json()) as {
|
||||
imageBase64: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
return base64ToBlob(out, outMime || "image/png");
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the in-browser remover */
|
||||
}
|
||||
}
|
||||
const inputBlob = await canvasBlob(image, 1600);
|
||||
const { removeBackground } = await import("@imgly/background-removal");
|
||||
return removeBackground(inputBlob, { output: { format: "image/png" } });
|
||||
}
|
||||
|
||||
// Outpaint / expand-canvas: pad the image with a neutral border, mark that
|
||||
// border WHITE in the mask, and run it through the same inpaint path as
|
||||
// generative-fill — no extra backend route needed.
|
||||
if (op.type === "outpaint") {
|
||||
const { imageBase64: padded, maskBase64: border } = padForOutpaint(
|
||||
image,
|
||||
typeof op.factor === "number" ? op.factor : 1.5,
|
||||
);
|
||||
const outParams: Record<string, unknown> = {
|
||||
strength: typeof op.strength === "number" ? op.strength : 0.85,
|
||||
};
|
||||
const outRes = await fetch(`${API}/edit`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
imageBase64: padded,
|
||||
mimeType: "image/png",
|
||||
op: {
|
||||
type: "generative-fill",
|
||||
prompt:
|
||||
op.prompt ||
|
||||
"Extend and continue the scene naturally, matching lighting, colors and perspective.",
|
||||
},
|
||||
maskBase64: border,
|
||||
params: outParams,
|
||||
}),
|
||||
});
|
||||
if (!outRes.ok) {
|
||||
const err = (await outRes.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `AI request failed (${outRes.status}).`);
|
||||
}
|
||||
const outJson = (await outRes.json()) as { imageBase64: string; mimeType?: string };
|
||||
return base64ToBlob(outJson.imageBase64, outJson.mimeType || "image/png");
|
||||
}
|
||||
|
||||
const { data, mimeType, width, height } = imageToBase64(image, 1280);
|
||||
// Masked ops carry an ImageData mask — rasterize it to a PNG matched to the
|
||||
// (downscaled) image dims, and strip it from the op since ImageData is not
|
||||
// JSON-serializable.
|
||||
const maskBase64 = "mask" in op ? maskToBase64(op.mask, width, height) : undefined;
|
||||
const wireOp: Record<string, unknown> = { type: op.type };
|
||||
if ("prompt" in op && typeof op.prompt === "string") wireOp.prompt = op.prompt;
|
||||
if ("factor" in op && typeof op.factor === "number") wireOp.factor = op.factor;
|
||||
|
||||
// Forward the "edit strength" slider (0..1) so the backend can scale the edit.
|
||||
const params: Record<string, unknown> = {};
|
||||
if ("strength" in op && typeof op.strength === "number") params.strength = op.strength;
|
||||
|
||||
const res = await fetch(`${API}/edit`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ imageBase64: data, mimeType, op: wireOp, maskBase64, params }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `AI request failed (${res.status}).`);
|
||||
}
|
||||
const { imageBase64, mimeType: outMime } = (await res.json()) as {
|
||||
imageBase64: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
return base64ToBlob(imageBase64, outMime || "image/png");
|
||||
},
|
||||
|
||||
// Voice annotation: record → (optional denoise) → transcribe. Both proxy
|
||||
// through server routes so the RunPod key stays server-side.
|
||||
async transcribeAudio(audioBase64: string) {
|
||||
const res = await fetch(`${API}/transcribe`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ audio: audioBase64 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `Transcription failed (${res.status}).`);
|
||||
}
|
||||
const { transcript } = (await res.json()) as { transcript?: string };
|
||||
return (transcript ?? "").trim();
|
||||
},
|
||||
|
||||
async denoiseAudio(audioBase64: string) {
|
||||
const res = await fetch(`${API}/denoise`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ audio: audioBase64 }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `Denoise failed (${res.status}).`);
|
||||
}
|
||||
const { audio } = (await res.json()) as { audio?: string };
|
||||
return audio ?? audioBase64;
|
||||
},
|
||||
|
||||
// Camera-tilt estimation is opt-in (needs the RunPod tilt endpoint deployed);
|
||||
// gate it so the editor's Auto-straighten button only appears when configured.
|
||||
estimateTilt: useRunpodTilt
|
||||
? async (_item: MediaItem, image: ImageSource) => {
|
||||
const { data } = imageToBase64(image, 1024);
|
||||
const res = await fetch(`${API}/tilt`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ image: data }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(err.error || `Tilt estimate failed (${res.status}).`);
|
||||
}
|
||||
return (await res.json()) as {
|
||||
rollDegrees: number;
|
||||
pitchDegrees: number;
|
||||
fovDegrees: number;
|
||||
};
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
"use client";
|
||||
|
||||
// Smart Gallery data layer. Serves EITHER a device-local mock (when the Shell isn't configured — the
|
||||
// demo keeps working offline) OR the live be-crm data door (crm.gallery.*), behind one StorageAdapter
|
||||
// so the embedded @photo-gallery/sdk is mode-agnostic. Tenant scoping, comment authorship and byte
|
||||
// authorization are all enforced server-side; this is just glue.
|
||||
//
|
||||
// Live contract (be-crm):
|
||||
// query crm.gallery.state.load {} -> PersistedState
|
||||
// cmd crm.gallery.state.apply StateChanges -> { upserted, removed }
|
||||
// cmd crm.gallery.media.presignUpload { mediaId, mime, sizeBytes, filename? } -> { ref, uploadUrl, method, headers? }
|
||||
// cmd crm.gallery.media.presignDownload { refs: string[] } -> { urls, expiresInSeconds }
|
||||
// query crm.gallery.stats {} -> { items, albums, people, bytes }
|
||||
// query crm.gallery.lock.status {} -> { hasPassword }
|
||||
// cmd crm.gallery.lock.set { password: string | null } -> { hasPassword }
|
||||
// cmd crm.gallery.lock.verify { password: string } -> { ok }
|
||||
//
|
||||
// BYTES NEVER PASS THROUGH be-crm OR THE BFF. `putMedia` mints a short-lived signed PUT URL and the
|
||||
// browser transfers straight to object storage — the same rule media-api.ts follows for attachments.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
|
||||
import {
|
||||
createLocalStorageAdapter,
|
||||
type GalleryFeatures,
|
||||
type GalleryUser,
|
||||
type MediaItem,
|
||||
type PersistedState,
|
||||
type StateChanges,
|
||||
type StorageAdapter,
|
||||
type StoredBlob,
|
||||
type ThemeTokens,
|
||||
} from "@photo-gallery/sdk";
|
||||
import { user as demoUser } from "@/components/dashboard/account-data";
|
||||
import { useMyAccess } from "@/lib/access";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
/** Device-local store key for demo mode. Namespaced so it can't collide with the SDK's own demo. */
|
||||
const LOCAL_STORE_KEY = "lup:smart-gallery:v1";
|
||||
|
||||
/** be-crm caps a single presignDownload at 500 refs — chunk anything larger. */
|
||||
const PRESIGN_CHUNK = 500;
|
||||
|
||||
/** Largest single upload the gallery will attempt (matches be-crm's gallery cap). */
|
||||
export const MAX_GALLERY_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Wire types (be-crm shapes) */
|
||||
/* ======================================================================== */
|
||||
|
||||
interface StateLoadDTO {
|
||||
media: MediaItem[];
|
||||
albums: PersistedState["albums"];
|
||||
people: PersistedState["people"];
|
||||
labelAliases?: Record<string, string>;
|
||||
deletedLabels?: string[];
|
||||
}
|
||||
|
||||
interface PresignUploadDTO {
|
||||
ref: string;
|
||||
uploadUrl: string;
|
||||
method: "PUT";
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PresignDownloadDTO {
|
||||
urls: Record<string, string>;
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
/** The subset of the AppShell SDK this module needs — keeps the adapter unit-testable. */
|
||||
interface DataDoor {
|
||||
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* The live adapter — be-crm data door */
|
||||
/* ======================================================================== */
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A StorageAdapter backed by the be-crm data door.
|
||||
*
|
||||
* Metadata (media/albums/people) rides the door as JSON; bytes go direct to object storage via
|
||||
* short-lived signed URLs. `storageRef` is the durable handle we persist — `src` is only ever a
|
||||
* signed URL with a TTL, so it is re-resolved from the refs on every `load()`.
|
||||
*/
|
||||
export function createDataDoorAdapter(sdk: DataDoor): StorageAdapter {
|
||||
/** Resolve durable refs → fresh signed GET URLs, in chunks the door will accept. */
|
||||
async function resolveRefs(refs: string[]): Promise<Record<string, string>> {
|
||||
const unique = [...new Set(refs.filter(Boolean))];
|
||||
if (!unique.length) return {};
|
||||
const urls: Record<string, string> = {};
|
||||
for (const group of chunk(unique, PRESIGN_CHUNK)) {
|
||||
const res = await sdk.command<PresignDownloadDTO>("crm.gallery.media.presignDownload", { refs: group });
|
||||
Object.assign(urls, res.urls ?? {});
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
return {
|
||||
name: "crm-data-door",
|
||||
|
||||
async load(): Promise<PersistedState | null> {
|
||||
const state = await sdk.query<StateLoadDTO>("crm.gallery.state.load", {});
|
||||
const media = state.media ?? [];
|
||||
// Signed URLs expire, so `src` is always rebuilt from `storageRef` at load time. Items with no
|
||||
// ref (e.g. a seeded remote URL) keep whatever `src` they were stored with.
|
||||
const urls = await resolveRefs(media.map((m) => m.storageRef ?? "").filter(Boolean));
|
||||
return {
|
||||
media: media.map((m) => (m.storageRef && urls[m.storageRef] ? { ...m, src: urls[m.storageRef]! } : m)),
|
||||
albums: state.albums ?? [],
|
||||
people: state.people ?? [],
|
||||
labelAliases: state.labelAliases ?? {},
|
||||
deletedLabels: state.deletedLabels ?? [],
|
||||
version: 1,
|
||||
};
|
||||
},
|
||||
|
||||
// Incremental sync is the real path (see applyChanges). `save` only runs if the store ever falls
|
||||
// back to whole-state persistence; express it as one big change set so behaviour is identical.
|
||||
async save(state: PersistedState): Promise<void> {
|
||||
await sdk.command("crm.gallery.state.apply", {
|
||||
upsertMedia: state.media,
|
||||
upsertAlbums: state.albums,
|
||||
upsertPeople: state.people,
|
||||
labelAliases: state.labelAliases ?? {},
|
||||
deletedLabels: state.deletedLabels ?? [],
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Incremental persistence — only the entities that actually changed. This is what makes a shared
|
||||
* tenant library safe for concurrent editors: two people touching different photos never clobber
|
||||
* each other, because neither sends the other's rows.
|
||||
*/
|
||||
async applyChanges(changes: StateChanges): Promise<void> {
|
||||
// StateChanges is a closed interface; the door takes an open variables bag.
|
||||
await sdk.command("crm.gallery.state.apply", { ...changes });
|
||||
},
|
||||
|
||||
/** Presign → direct PUT → resolve a display URL. Bytes never touch be-crm or the BFF. */
|
||||
async putMedia(id: string, blob: Blob, meta: { name: string; mime: string }): Promise<StoredBlob> {
|
||||
if (blob.size > MAX_GALLERY_BYTES) {
|
||||
throw new Error(`File is too large (max ${Math.floor(MAX_GALLERY_BYTES / (1024 * 1024))} MB).`);
|
||||
}
|
||||
const mime = meta.mime || blob.type || "application/octet-stream";
|
||||
const presigned = await sdk.command<PresignUploadDTO>("crm.gallery.media.presignUpload", {
|
||||
mediaId: id,
|
||||
mime,
|
||||
sizeBytes: blob.size,
|
||||
filename: meta.name,
|
||||
});
|
||||
const res = await fetch(presigned.uploadUrl, {
|
||||
method: presigned.method ?? "PUT",
|
||||
headers: { "content-type": mime, ...(presigned.headers ?? {}) },
|
||||
body: blob,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
|
||||
const urls = await resolveRefs([presigned.ref]);
|
||||
return { ref: presigned.ref, url: urls[presigned.ref] ?? presigned.ref };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Public hooks */
|
||||
/* ======================================================================== */
|
||||
|
||||
export interface GalleryStorage {
|
||||
/** True when persisting to be-crm; false when running on the device-local demo store. */
|
||||
live: boolean;
|
||||
adapter: StorageAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage backend for the embedded gallery. Live against the be-crm data door once the Shell is
|
||||
* configured, otherwise a device-local store so the demo works with no backend at all.
|
||||
*
|
||||
* SHELL is a build-time constant, so this branch is stable across renders (Rules-of-Hooks safe).
|
||||
*/
|
||||
export function useGalleryStorage(): GalleryStorage {
|
||||
const { sdk } = useAppShell();
|
||||
// The adapter identity must be stable — PhotoGallery captures it in a ref on first render.
|
||||
return useMemo<GalleryStorage>(
|
||||
() =>
|
||||
SHELL
|
||||
? { live: true, adapter: createDataDoorAdapter(sdk as unknown as DataDoor) }
|
||||
: { live: false, adapter: createLocalStorageAdapter(LOCAL_STORE_KEY) },
|
||||
[sdk],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed-in identity handed to the gallery so comments are attributed to a real person rather
|
||||
* than a free-text name the author can type themselves.
|
||||
*
|
||||
* Falls back to the same static demo user the sidebar and topbar use when the Shell isn't wired, so
|
||||
* the comment module behaves identically in the demo — the CRM never shows an anonymous author.
|
||||
* be-crm re-stamps `authorId` from the PAT on every write regardless, so this value is a display
|
||||
* convenience, never the source of authority.
|
||||
*/
|
||||
export function useGalleryUser(): GalleryUser {
|
||||
const { user, context } = useAuth();
|
||||
return useMemo<GalleryUser>(() => {
|
||||
if (!user) return { id: demoUser.id, name: demoUser.name };
|
||||
const avatarUrl = user.avatarUrl ?? context?.principal?.avatarUrl;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.displayName || user.email || demoUser.name,
|
||||
...(user.email ? { email: user.email } : {}),
|
||||
...(avatarUrl ? { avatarUrl } : {}),
|
||||
};
|
||||
}, [user, context]);
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Recently Deleted lock */
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* The SDK's `lockProvider` contract, declared here rather than imported so this module keeps
|
||||
* compiling against an SDK build that predates the prop. It is structurally identical to
|
||||
* `PhotoGalleryProps['lockProvider']`.
|
||||
*/
|
||||
export interface GalleryLockProvider {
|
||||
status(): Promise<{ hasPassword: boolean }>;
|
||||
/** `null` clears the password. */
|
||||
set(password: string | null): Promise<void>;
|
||||
verify(password: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A server-backed, per-user lock for the Recently Deleted view.
|
||||
*
|
||||
* Without this the SDK falls back to a device-local `localStorage` hash: the lock exists only on
|
||||
* the browser that set it, so the same account on a second device sees an unlocked trash. Backed
|
||||
* by `crm.gallery.lock.*`, the password becomes an account property — be-crm stores only a
|
||||
* scrypt hash with a per-record random salt, keyed by (tenant, principal), and rate-limits verify.
|
||||
*
|
||||
* Returns `undefined` in demo mode ON PURPOSE: with no backend there is nowhere to put the hash,
|
||||
* and the SDK's own device-local behaviour is the right fallback for a demo.
|
||||
*
|
||||
* SHELL is a build-time constant, so this branch is stable across renders (Rules-of-Hooks safe).
|
||||
*/
|
||||
export function useGalleryLockProvider(): GalleryLockProvider | undefined {
|
||||
const { sdk } = useAppShell();
|
||||
return useMemo<GalleryLockProvider | undefined>(() => {
|
||||
if (!SHELL) return undefined;
|
||||
const door = sdk as unknown as DataDoor;
|
||||
return {
|
||||
status: () => door.query<{ hasPassword: boolean }>("crm.gallery.lock.status", {}),
|
||||
// The SDK's contract returns void; the door's `{ hasPassword }` is redundant after a set.
|
||||
set: async (password) => {
|
||||
await door.command("crm.gallery.lock.set", { password });
|
||||
},
|
||||
// A wrong password is a normal `{ ok: false }`, not an error. A 403 (the verify lockout)
|
||||
// still throws, which is what the SDK's prompt should surface.
|
||||
verify: async (password) => (await door.command<{ ok: boolean }>("crm.gallery.lock.verify", { password })).ok,
|
||||
};
|
||||
}, [sdk]);
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Permission-gated features */
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* The gallery capabilities the CRM gates behind team permissions. Each SDK feature toggle
|
||||
* (and the whole-view `media.view` gate) maps to one permission id from be-crm's Media group.
|
||||
* The SDK still enforces nothing here — hiding a control is UX; be-crm enforces every write via
|
||||
* GALLERY_VISIBILITY + tenant scoping regardless of what the UI shows.
|
||||
*/
|
||||
const MEDIA_PERMISSIONS = [
|
||||
"media.view", "media.upload", "media.capture", "media.edit",
|
||||
"media.delete", "media.export", "media.share", "media.map", "media.ai",
|
||||
] as const;
|
||||
|
||||
/** SDK feature toggle → the permission id that unlocks it. */
|
||||
const FEATURE_PERMISSION: Record<keyof GalleryFeatures, string> = {
|
||||
editor: "media.edit",
|
||||
camera: "media.capture",
|
||||
import: "media.upload",
|
||||
export: "media.export",
|
||||
sharing: "media.share",
|
||||
map: "media.map",
|
||||
ai: "media.ai",
|
||||
};
|
||||
|
||||
export interface ResolvedGalleryFeatures {
|
||||
/** Feature toggles to hand the SDK's `features` prop, resolved from the caller's permissions. */
|
||||
features: GalleryFeatures;
|
||||
/** Whether the whole Smart Gallery view should render at all (`media.view`). */
|
||||
canView: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the gallery's feature flags + view access from the signed-in user's CRM permissions.
|
||||
*
|
||||
* Fallback rule (SAFE default — a control is enabled unless the user is positively known to lack it):
|
||||
* 1. While access is still loading, stay permissive so features don't flash on then vanish.
|
||||
* 2. Superadmins and owners get everything (they hold every permission anyway; this is explicit).
|
||||
* 3. A member who has been assigned AT LEAST ONE `media.*` permission is gated precisely: each
|
||||
* feature is enabled only if its mapped permission is in their list.
|
||||
* 4. A member with ZERO `media.*` permissions assigned is treated as fully enabled. Roles are not
|
||||
* configured with Media perms until an admin opts in (§5 just made them assignable), so gating a
|
||||
* freshly-seeded member down to nothing would cripple the gallery before anyone could grant them
|
||||
* anything. The "has at least one media.* perm" signal is what flips a role from this permissive
|
||||
* default into precise per-feature gating.
|
||||
*
|
||||
* Because be-crm's mock access (demo, no Shell) returns superadmin + all perms, the demo shows
|
||||
* everything via rule 2.
|
||||
*/
|
||||
export function useGalleryFeatures(): ResolvedGalleryFeatures {
|
||||
const access = useMyAccess();
|
||||
return useMemo<ResolvedGalleryFeatures>(() => {
|
||||
const has = (p: string) => access.permissions.includes(p);
|
||||
const privileged = access.isSuperadmin || access.roleSlugs.includes("owner");
|
||||
const hasAnyMedia = MEDIA_PERMISSIONS.some(has);
|
||||
// Permissive whenever we can't (yet) prove the user lacks a permission: loading, privileged, or a
|
||||
// member who has no Media perms assigned at all. Otherwise gate precisely on the mapped id.
|
||||
const allow = (perm: string) => access.loading || privileged || !hasAnyMedia || has(perm);
|
||||
|
||||
const features: GalleryFeatures = {
|
||||
editor: allow(FEATURE_PERMISSION.editor),
|
||||
camera: allow(FEATURE_PERMISSION.camera),
|
||||
ai: allow(FEATURE_PERMISSION.ai),
|
||||
map: allow(FEATURE_PERMISSION.map),
|
||||
import: allow(FEATURE_PERMISSION.import),
|
||||
export: allow(FEATURE_PERMISSION.export),
|
||||
sharing: allow(FEATURE_PERMISSION.sharing),
|
||||
};
|
||||
|
||||
return { features, canView: allow("media.view") };
|
||||
}, [access]);
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Theme bridge */
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Maps the gallery's design tokens onto the CRM's own CSS variables.
|
||||
*
|
||||
* Every value is a `var(--crm-token)` reference rather than a literal hex, so the gallery inherits the
|
||||
* dashboard's palette through the SAME `[data-theme]` cascade the rest of the app uses — light/dark
|
||||
* switch together, and a future palette change reaches the gallery with no code edit here.
|
||||
*/
|
||||
export const GALLERY_THEME_TOKENS: ThemeTokens = {
|
||||
// Surfaces
|
||||
bgLight: "var(--bg)",
|
||||
bgDark: "var(--bg)",
|
||||
elevatedLight: "var(--panel-3)",
|
||||
elevatedDark: "var(--panel-3)",
|
||||
sidebarBgLight: "var(--sidebar)",
|
||||
sidebarBgDark: "var(--sidebar)",
|
||||
toolbarBgLight: "var(--panel)",
|
||||
toolbarBgDark: "var(--panel)",
|
||||
cardLight: "var(--panel)",
|
||||
cardDark: "var(--panel)",
|
||||
cardHoverLight: "var(--panel-3)",
|
||||
cardHoverDark: "var(--panel-3)",
|
||||
menuBgLight: "var(--panel-3)",
|
||||
menuBgDark: "var(--panel-3)",
|
||||
|
||||
// Text
|
||||
textLight: "var(--text)",
|
||||
textDark: "var(--text)",
|
||||
textSecondaryLight: "var(--muted)",
|
||||
textSecondaryDark: "var(--muted)",
|
||||
textTertiaryLight: "var(--faint)",
|
||||
textTertiaryDark: "var(--faint)",
|
||||
|
||||
// Lines + washes
|
||||
separatorLight: "var(--border)",
|
||||
separatorDark: "var(--border)",
|
||||
separatorStrongLight: "var(--border-2)",
|
||||
separatorStrongDark: "var(--border-2)",
|
||||
hoverLight: "var(--track)",
|
||||
hoverDark: "var(--track)",
|
||||
activeLight: "var(--border-2)",
|
||||
activeDark: "var(--border-2)",
|
||||
sidebarSelectedLight: "var(--track)",
|
||||
sidebarSelectedDark: "var(--track)",
|
||||
glassBorderLight: "var(--border-2)",
|
||||
glassBorderDark: "var(--border-2)",
|
||||
|
||||
// Brand
|
||||
accent: "var(--orange)",
|
||||
accentStrongLight: "var(--orange-2)",
|
||||
accentStrongDark: "var(--orange-2)",
|
||||
accentContrast: "#ffffff",
|
||||
dangerLight: "var(--red)",
|
||||
dangerDark: "var(--red)",
|
||||
tileFav: "var(--red)",
|
||||
segmentedActive: "var(--panel-3)",
|
||||
|
||||
// Overlays + chrome
|
||||
overlayBg: "rgba(2,2,6,0.94)",
|
||||
editorBg: "var(--panel-2)",
|
||||
shadowSm: "0 1px 2px rgba(0,0,0,0.18)",
|
||||
shadowMdLight: "var(--shadow)",
|
||||
shadowMdDark: "var(--shadow)",
|
||||
shadowLgLight: "0 30px 70px -20px rgba(15,23,42,0.25)",
|
||||
shadowLgDark: "0 30px 70px -20px rgba(0,0,0,0.7)",
|
||||
|
||||
fontFamily: "var(--font)",
|
||||
radiusMenu: 14,
|
||||
sidebarRadius: 14,
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
// Offline notifications data layer (Web Push). Registers the service worker, subscribes the browser
|
||||
// with IIOS's VAPID key, and hands the subscription to the be-crm door so IIOS can reach this user
|
||||
// while no CRM tab is open. Push needs a real backend (VAPID key + delivery), so it is only offered
|
||||
// when the Shell is configured (live); demo mode reports it unsupported.
|
||||
//
|
||||
// Live contract (be-crm data door → IIOS /v1/notifications/*):
|
||||
// query crm.messenger.push.vapidKey {} -> { key } ('' = push disabled server-side)
|
||||
// cmd crm.messenger.push.subscribe { endpoint, keys, userAgent } -> { ok }
|
||||
// cmd crm.messenger.push.unsubscribe { endpoint } -> { ok }
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
const SW_URL = "/push-sw.js";
|
||||
|
||||
export type PushPermission = "default" | "granted" | "denied";
|
||||
|
||||
export interface PushState {
|
||||
/** Browser can do Web Push AND we have a live backend to deliver it. */
|
||||
supported: boolean;
|
||||
/** OS/browser permission for notifications. */
|
||||
permission: PushPermission;
|
||||
/** This browser currently has an active push subscription registered with the backend. */
|
||||
subscribed: boolean;
|
||||
/** A subscribe/unsubscribe round-trip is in flight. */
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
enable: () => Promise<void>;
|
||||
disable: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** VAPID keys travel as URL-safe base64; PushManager wants raw bytes. */
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(normalized);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function browserSupportsPush(): boolean {
|
||||
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
/** Serialize a PushSubscription into the door's { endpoint, keys } shape. */
|
||||
function toSubscribeBody(sub: PushSubscription): { endpoint: string; keys: { p256dh: string; auth: string }; userAgent: string } {
|
||||
const json = sub.toJSON();
|
||||
return {
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: json.keys?.p256dh ?? "", auth: json.keys?.auth ?? "" },
|
||||
userAgent: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 400) : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function usePushNotifications(): PushState {
|
||||
const { sdk } = useAppShell();
|
||||
const [supported] = useState<boolean>(() => SHELL && browserSupportsPush());
|
||||
const [permission, setPermission] = useState<PushPermission>("default");
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reflect the current OS permission + whether a subscription already exists (e.g. across reloads).
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
setPermission(Notification.permission as PushPermission);
|
||||
let cancelled = false;
|
||||
navigator.serviceWorker.ready
|
||||
.then((reg) => reg.pushManager.getSubscription())
|
||||
.then((sub) => {
|
||||
if (!cancelled) setSubscribed(Boolean(sub));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [supported]);
|
||||
|
||||
const enable = useCallback(async () => {
|
||||
if (!supported) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const perm = await Notification.requestPermission();
|
||||
setPermission(perm as PushPermission);
|
||||
if (perm !== "granted") throw new Error("Notifications permission was not granted.");
|
||||
|
||||
const reg = await navigator.serviceWorker.register(SW_URL);
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
const { key } = await sdk.query<{ key: string }>("crm.messenger.push.vapidKey", {});
|
||||
if (!key) throw new Error("Push is not enabled on the server (no VAPID key).");
|
||||
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
const sub =
|
||||
existing ??
|
||||
(await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
// Cast: this lib's BufferSource type pins ArrayBuffer, but a plain Uint8Array is valid here.
|
||||
applicationServerKey: urlBase64ToUint8Array(key) as BufferSource,
|
||||
}));
|
||||
|
||||
await sdk.command("crm.messenger.push.subscribe", toSubscribeBody(sub));
|
||||
setSubscribed(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not enable notifications.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [supported, sdk]);
|
||||
|
||||
const disable = useCallback(async () => {
|
||||
if (!supported) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
// Tell the backend first (still has the endpoint), then drop the local subscription.
|
||||
await sdk.command("crm.messenger.push.unsubscribe", { endpoint: sub.endpoint }).catch(() => {});
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
setSubscribed(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not disable notifications.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [supported, sdk]);
|
||||
|
||||
return { supported, permission, subscribed, busy, error, enable, disable };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
// One shared IIOS message socket for the whole dashboard, so the messenger tab AND the app-wide
|
||||
// notification center use a single connection (not one each). Opened at the dashboard level and
|
||||
// kept alive across tab switches; the messenger tab reuses it via useRealtime().
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { MessageSocket } from "@insignia/iios-kernel-client";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
const RealtimeContext = createContext<MessageSocket | null>(null);
|
||||
interface RealtimeDTO { url: string; audience: string; token?: string }
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
function LiveRealtimeProvider({ children }: { children: ReactNode }) {
|
||||
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
|
||||
const [socket, setSocket] = useState<MessageSocket | null>(null);
|
||||
const url = rt.data?.url;
|
||||
const token = rt.data?.token;
|
||||
useEffect(() => {
|
||||
if (!url || !token) return;
|
||||
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
|
||||
s.connect();
|
||||
setSocket(s);
|
||||
return () => {
|
||||
s.disconnect();
|
||||
setSocket(null);
|
||||
};
|
||||
}, [url, token]);
|
||||
return <RealtimeContext.Provider value={socket}>{children}</RealtimeContext.Provider>;
|
||||
}
|
||||
|
||||
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
// SHELL is a build-time constant, so the same branch runs every render (Rules-of-Hooks safe).
|
||||
if (!SHELL) return <>{children}</>;
|
||||
return <LiveRealtimeProvider>{children}</LiveRealtimeProvider>;
|
||||
}
|
||||
|
||||
/** The shared socket, or null (demo mode / not yet connected). */
|
||||
export function useRealtime(): MessageSocket | null {
|
||||
return useContext(RealtimeContext);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
// Global conversation search. Live path calls the be-crm data door (crm.search → IIOS Meilisearch,
|
||||
// permission-scoped there); demo path filters a small in-memory set. Search is imperative (the query
|
||||
// changes on every keystroke), so it uses sdk.query directly rather than the cached useQuery hook.
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useAppShell } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
export interface SearchResult {
|
||||
interactionId: string;
|
||||
threadId: string;
|
||||
surface: "messenger" | "inbox";
|
||||
title: string;
|
||||
/** Snippet with <em>…</em> around the matched terms. */
|
||||
snippet: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export type SearchFn = (query: string) => Promise<SearchResult[]>;
|
||||
|
||||
const MOCK: SearchResult[] = [
|
||||
{ interactionId: "s1", threadId: "th_mock_1", surface: "messenger", title: "Sofia Ramirez", snippet: "Can you confirm the <em>Henderson</em> scope?", at: Date.now() },
|
||||
{ interactionId: "s2", threadId: "th_mock_2", surface: "messenger", title: "Storm response — East side", snippet: "Crew is <em>rolling</em> out at 7", at: Date.now() },
|
||||
{ interactionId: "s3", threadId: "mt_invoice", surface: "inbox", title: "Invoice #1042 — Acme Roofing", snippet: "Attached is <em>invoice</em> #1042 for the East-side job", at: Date.now() },
|
||||
];
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
function useLiveSearch(): SearchFn {
|
||||
const { sdk } = useAppShell();
|
||||
return useCallback(async (query: string) => {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
return sdk.query<SearchResult[]>("crm.search", { query: q, limit: 20 });
|
||||
}, [sdk]);
|
||||
}
|
||||
|
||||
function useMockSearch(): SearchFn {
|
||||
return useCallback(async (query: string) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return MOCK.filter((m) => (m.title + " " + m.snippet).toLowerCase().includes(q));
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useGlobalSearch(): SearchFn {
|
||||
return SHELL ? useLiveSearch() : useMockSearch();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Tiny in-process fixed-window rate limiter for the Smart Gallery AI routes.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* SCOPE AND LIMITATIONS — READ BEFORE RELYING ON THIS
|
||||
* ---------------------------------------------------------------------------
|
||||
* State lives in a plain `Map` in THIS process's memory. That means:
|
||||
*
|
||||
* - PER-INSTANCE, NOT GLOBAL. With N app instances behind a load balancer a
|
||||
* caller gets up to N x the configured budget. On serverless platforms each
|
||||
* cold start begins with an empty map, so the effective limit is weaker
|
||||
* still.
|
||||
* - NOT DURABLE. A restart or redeploy clears every counter.
|
||||
* - FIXED WINDOW, NOT SLIDING. A caller can burst `max` at the very end of one
|
||||
* window and `max` again at the start of the next — up to 2x `max` across a
|
||||
* window boundary. Acceptable here; the goal is to bound runaway cost, not
|
||||
* to meter precisely.
|
||||
*
|
||||
* It exists because the routes it guards spend real money on GPU inference and
|
||||
* shipping them with NO limit at all is worse than shipping an imperfect one.
|
||||
*
|
||||
* REPLACE WITH REDIS (or the platform's rate limiter) BEFORE RUNNING MORE THAN
|
||||
* ONE INSTANCE. The `limit()` signature is deliberately narrow so a Redis-backed
|
||||
* implementation can drop straight in — the only change needed is making it
|
||||
* async at the call sites.
|
||||
*
|
||||
* This is a throttle, not an authorization check. See src/lib/server/session.ts.
|
||||
*/
|
||||
|
||||
interface Window {
|
||||
/** Requests counted so far in the current window. */
|
||||
count: number;
|
||||
/** Epoch ms at which the current window ends. */
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const windows = new Map<string, Window>();
|
||||
|
||||
/** Drop expired entries so the map cannot grow without bound. */
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
let lastSweep = 0;
|
||||
|
||||
function sweep(now: number): void {
|
||||
if (now - lastSweep < SWEEP_INTERVAL_MS) return;
|
||||
lastSweep = now;
|
||||
for (const [key, w] of windows) {
|
||||
if (w.resetAt <= now) windows.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export interface LimitResult {
|
||||
ok: boolean;
|
||||
/** Seconds until the window resets. Send as `Retry-After` when `ok` is false. */
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count one request against `key` and report whether it is allowed.
|
||||
*
|
||||
* @param key Caller identity — use `rateLimitKey()` from ./session.
|
||||
* @param max Requests allowed per window.
|
||||
* @param windowMs Window length in ms.
|
||||
*/
|
||||
export function limit(key: string, max: number, windowMs: number): LimitResult {
|
||||
const now = Date.now();
|
||||
sweep(now);
|
||||
|
||||
const existing = windows.get(key);
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
windows.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
if (existing.count >= max) {
|
||||
return { ok: false, retryAfter: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) };
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
/** Test/maintenance helper — clears all counters. */
|
||||
export function resetAllLimits(): void {
|
||||
windows.clear();
|
||||
lastSweep = 0;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* base64 / data-URI helpers shared by the RunPod endpoint wrappers.
|
||||
* Different endpoints name their image field differently and some prefix a
|
||||
* `data:` URI — these helpers normalize both.
|
||||
*
|
||||
* PROVENANCE: a faithful port of
|
||||
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/base64.ts`.
|
||||
* The `pickOutputImage` normalization is intentionally identical so both apps
|
||||
* tolerate the same set of endpoint response shapes.
|
||||
*
|
||||
* SERVER-ONLY.
|
||||
*/
|
||||
|
||||
/** "data:image/png;base64,XXXX" -> "XXXX" (leaves a bare base64 string untouched). */
|
||||
export function stripDataUri(s: string): string {
|
||||
if (!s.startsWith("data:")) return s;
|
||||
const i = s.indexOf(",");
|
||||
return i === -1 ? s : s.slice(i + 1);
|
||||
}
|
||||
|
||||
/** Wrap a bare base64 string in a data: URI. */
|
||||
export function toDataUri(b64: string, mime: string): string {
|
||||
return `data:${mime};base64,${stripDataUri(b64)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the first image-like base64 string out of a RunPod endpoint's `output`,
|
||||
* regardless of which field name it used. Handles the common shapes:
|
||||
* "iVBOR..." (raw string)
|
||||
* { image: "..." } / { image_png } / { image_base64 }
|
||||
* { images: ["..."] } (array)
|
||||
* { output: { image: "..." } } (nested)
|
||||
*/
|
||||
export function pickOutputImage(output: unknown): string {
|
||||
const s = findImageString(output, false);
|
||||
if (!s) throw new Error("RunPod output did not contain an image.");
|
||||
return stripDataUri(s);
|
||||
}
|
||||
|
||||
/** Keys whose name implies the value IS the image — any non-empty string is accepted. */
|
||||
const IMAGE_KEYS = ["image_png", "image", "image_base64", "images"] as const;
|
||||
/** Generic wrapper keys — a string here must actually look like image data. */
|
||||
const CONTAINER_KEYS = ["output", "result", "data"] as const;
|
||||
|
||||
/** A base64 image payload is long; a status/id string ("success", "job-abc") is short. */
|
||||
function looksLikeImageData(s: string): boolean {
|
||||
if (s.startsWith("data:image/")) return true;
|
||||
return s.length >= 256 && /^[A-Za-z0-9+/=\s]+$/.test(s.slice(0, 256));
|
||||
}
|
||||
|
||||
/**
|
||||
* `strict` is true when we descended through a generic wrapper key (output/result/
|
||||
* data), where a bare string could be a status/id rather than an image — so it must
|
||||
* pass `looksLikeImageData`. Under an explicit image key (or at top level) any
|
||||
* non-empty string is taken as the image.
|
||||
*/
|
||||
function findImageString(value: unknown, strict: boolean, depth = 0): string | undefined {
|
||||
if (depth > 5) return undefined;
|
||||
if (typeof value === "string") {
|
||||
if (!value) return undefined;
|
||||
return !strict || looksLikeImageData(value) ? value : undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const el of value) {
|
||||
const s = findImageString(el, strict, depth + 1);
|
||||
if (s) return s;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const o = value as Record<string, unknown>;
|
||||
for (const key of IMAGE_KEYS) {
|
||||
if (key in o) {
|
||||
const s = findImageString(o[key], false, depth + 1);
|
||||
if (s) return s;
|
||||
}
|
||||
}
|
||||
for (const key of CONTAINER_KEYS) {
|
||||
if (key in o) {
|
||||
const s = findImageString(o[key], true, depth + 1);
|
||||
if (s) return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Low-level RunPod transport. Every model runs as its own RunPod endpoint; this
|
||||
* handles the common request envelope, bearer auth, and both response modes:
|
||||
*
|
||||
* - `/runsync` (preferred): the job runs synchronously and the body already
|
||||
* contains `output` (or IS the output for a custom handler).
|
||||
* - `/run`: returns `{ id, status }`; we poll `/status/{id}` until the job
|
||||
* reaches a terminal state or the time budget (kept under the 60s serverless
|
||||
* function cap) is exhausted.
|
||||
*
|
||||
* RUNPOD_API_KEY is read here so callers never handle the secret directly, and
|
||||
* it is NEVER echoed into an error message. Upstream error bodies are truncated
|
||||
* to 160 chars before being surfaced.
|
||||
*
|
||||
* PROVENANCE: a faithful port of
|
||||
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/client.ts` (same 55s
|
||||
* budget, same polling, same RunpodError.status mapping).
|
||||
*
|
||||
* SERVER-ONLY.
|
||||
*/
|
||||
|
||||
export class RunpodError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 502) {
|
||||
super(message);
|
||||
this.name = "RunpodError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunpodCallOpts {
|
||||
/** Human label used in error messages, e.g. "sd-inpaint". */
|
||||
name: string;
|
||||
/** Full endpoint URL from the per-model env var (…/runsync or …/run). */
|
||||
url: string;
|
||||
input: Record<string, unknown>;
|
||||
/** Total budget for the whole call incl. polling. Default 55s (< the 60s cap). */
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
interface RunpodEnvelope {
|
||||
id?: string;
|
||||
status?: string;
|
||||
output?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export async function runpodCall<TOut = unknown>(opts: RunpodCallOpts): Promise<TOut> {
|
||||
const { name, url, input } = opts;
|
||||
const timeoutMs = opts.timeoutMs ?? 55_000;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 1500;
|
||||
|
||||
const key = process.env.RUNPOD_API_KEY;
|
||||
if (!key) throw new RunpodError("RUNPOD_API_KEY is not set.", 500);
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const authHeaders = { authorization: `Bearer ${key}` };
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...authHeaders },
|
||||
body: JSON.stringify({ input }),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
throw new RunpodError(`Could not reach RunPod (${name}).`, 502);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Truncated on purpose — never surface a full upstream body to the client.
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 160);
|
||||
throw new RunpodError(`RunPod ${name} error (${res.status}). ${detail}`.trim(), 502);
|
||||
}
|
||||
|
||||
const data = (await res.json().catch(() => null)) as RunpodEnvelope | null;
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new RunpodError(`RunPod ${name} returned an invalid response.`, 502);
|
||||
}
|
||||
|
||||
// Terminal failure reported in a job envelope.
|
||||
if (data.status === "FAILED" || data.status === "CANCELLED") {
|
||||
throw new RunpodError(`RunPod ${name} job ${data.status.toLowerCase()}.`, 502);
|
||||
}
|
||||
// No job id → this is not an async envelope; the body itself is the output.
|
||||
// Covers custom /runsync handlers that return their result directly, even when
|
||||
// it carries a `status` field (e.g. "success").
|
||||
if (data.id === undefined) {
|
||||
return (data.output !== undefined ? data.output : data) as TOut;
|
||||
}
|
||||
// Job envelope that already carries a completed/inline output.
|
||||
if (data.output !== undefined && (data.status === undefined || data.status === "COMPLETED")) {
|
||||
return data.output as TOut;
|
||||
}
|
||||
|
||||
// Async: poll /status/{id} until COMPLETED / FAILED / the time budget runs out.
|
||||
// Each wait + fetch is clamped to the remaining budget so the whole call stays
|
||||
// under `timeoutMs` (kept below the 60s function cap).
|
||||
const statusUrl = url.replace(/\/run(sync)?(\/?)$/, `/status/${data.id}`);
|
||||
for (;;) {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining < 500) break; // not enough budget for another round
|
||||
await sleep(Math.min(pollIntervalMs, remaining));
|
||||
const left = deadline - Date.now();
|
||||
if (left <= 0) break;
|
||||
let sres: Response;
|
||||
try {
|
||||
sres = await fetch(statusUrl, {
|
||||
headers: authHeaders,
|
||||
signal: AbortSignal.timeout(Math.min(10_000, Math.max(1000, left))),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
continue; // transient — keep polling until the deadline
|
||||
}
|
||||
if (!sres.ok) continue;
|
||||
const sdata = (await sres.json().catch(() => null)) as RunpodEnvelope | null;
|
||||
if (!sdata) continue;
|
||||
if (sdata.status === "COMPLETED") return sdata.output as TOut;
|
||||
if (sdata.status === "FAILED" || sdata.status === "CANCELLED") {
|
||||
throw new RunpodError(`RunPod ${name} job ${sdata.status.toLowerCase()}.`, 502);
|
||||
}
|
||||
}
|
||||
throw new RunpodError(
|
||||
`RunPod ${name} timed out (raise the endpoint's speed or use /runsync).`,
|
||||
504,
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Typed wrappers for each RunPod endpoint behind the Smart Gallery.
|
||||
* Every function reads its own `RUNPOD_*_URL` env var, sends the exact `input`
|
||||
* contract the model spec documents, and normalizes the response.
|
||||
*
|
||||
* PROVENANCE: a faithful port of
|
||||
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/endpoints.ts` —
|
||||
* identical `normalizeDetections` / `normalizeBox` logic and identical env var
|
||||
* names, so an endpoint deployed for the SDK demo works here unchanged.
|
||||
*
|
||||
* SERVER-ONLY. Missing/invalid URLs throw a RunpodError(500) so an unconfigured
|
||||
* op surfaces as a clear message rather than a crash.
|
||||
*/
|
||||
|
||||
import { stripDataUri, pickOutputImage } from "./base64";
|
||||
import { RunpodError, runpodCall } from "./client";
|
||||
import type {
|
||||
Img2ImgReq,
|
||||
InpaintReq,
|
||||
RunpodDetection,
|
||||
RunpodImageResult,
|
||||
TiltResult,
|
||||
TranscriptResult,
|
||||
} from "./types";
|
||||
|
||||
function envNum(v: string | undefined, fallback: number): number {
|
||||
// Treat a blank/whitespace env var as unset — Number('') is 0 (finite), which
|
||||
// would otherwise send e.g. strength:0 for `RUNPOD_SD_STRENGTH=`.
|
||||
if (v == null || v.trim() === "") return fallback;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function endpointUrl(envVar: string): string {
|
||||
const url = process.env[envVar];
|
||||
if (!url || !/^https?:\/\//i.test(url)) {
|
||||
throw new RunpodError(`${envVar} is not set (or is not an http(s) URL).`, 500);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Run an image-in/image-out endpoint and normalize the result to base64 PNG. */
|
||||
async function imageOp(
|
||||
name: string,
|
||||
envVar: string,
|
||||
input: Record<string, unknown>,
|
||||
): Promise<RunpodImageResult> {
|
||||
const output = await runpodCall({ name, url: endpointUrl(envVar), input });
|
||||
return { imageBase64: pickOutputImage(output), mimeType: "image/png" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** #6 Background removal (U²-Net via rembg). model: u2net | u2netp | u2net_human_seg */
|
||||
export function rpRemoveBackground(imageB64: string, model?: string): Promise<RunpodImageResult> {
|
||||
return imageOp("background-removal", "RUNPOD_BG_REMOVE_URL", {
|
||||
task: "remove-bg",
|
||||
image: imageB64,
|
||||
...(model ? { model_name: model } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** #7 Real-ESRGAN enhance/upscale (RealESRGAN_x4plus). The caller's scale (from the
|
||||
* op / restore pass) is authoritative — it is not overridden by any env default. */
|
||||
export function rpUpscale(
|
||||
imageB64: string,
|
||||
scale: 2 | 4,
|
||||
faceEnhance = false,
|
||||
): Promise<RunpodImageResult> {
|
||||
return imageOp("upscale", "RUNPOD_UPSCALE_URL", {
|
||||
task: "upscale",
|
||||
image: imageB64,
|
||||
scale,
|
||||
face_enhance: faceEnhance,
|
||||
});
|
||||
}
|
||||
|
||||
/** #8 DDColor B&W → colorize. */
|
||||
export function rpColorize(imageB64: string, inputSize?: number): Promise<RunpodImageResult> {
|
||||
return imageOp("colorize", "RUNPOD_COLORIZE_URL", {
|
||||
image: imageB64,
|
||||
...(inputSize ? { input_size: inputSize } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** #9 SD 3.5 masked inpainting (sky fix / eraser / fill). Also #11 outpaint (pre-padded). */
|
||||
export function rpInpaint(p: InpaintReq): Promise<RunpodImageResult> {
|
||||
const input: Record<string, unknown> = {
|
||||
task: "inpaint",
|
||||
image: p.imageB64,
|
||||
mask: p.maskB64,
|
||||
prompt: p.prompt,
|
||||
strength: p.strength ?? envNum(process.env.RUNPOD_SD_STRENGTH, 0.8),
|
||||
guidance_scale: p.guidanceScale ?? envNum(process.env.RUNPOD_SD_GUIDANCE, 7),
|
||||
num_inference_steps: p.steps ?? envNum(process.env.RUNPOD_SD_STEPS, 35),
|
||||
};
|
||||
const negative = p.negativePrompt ?? process.env.RUNPOD_SD_NEGATIVE_PROMPT;
|
||||
if (negative) input.negative_prompt = negative;
|
||||
if (p.seed != null) input.seed = p.seed;
|
||||
return imageOp("sd-inpaint", "RUNPOD_SD_INPAINT_URL", input);
|
||||
}
|
||||
|
||||
/** #10 SD 3.5 general prompt edit (img2img, no mask). */
|
||||
export function rpImg2Img(p: Img2ImgReq): Promise<RunpodImageResult> {
|
||||
const input: Record<string, unknown> = {
|
||||
task: "img2img",
|
||||
image: p.imageB64,
|
||||
prompt: p.prompt,
|
||||
strength: p.strength ?? envNum(process.env.RUNPOD_SD_STRENGTH, 0.6),
|
||||
guidance_scale: p.guidanceScale ?? envNum(process.env.RUNPOD_SD_GUIDANCE, 7),
|
||||
num_inference_steps: p.steps ?? envNum(process.env.RUNPOD_SD_STEPS, 35),
|
||||
};
|
||||
const negative = p.negativePrompt ?? process.env.RUNPOD_SD_NEGATIVE_PROMPT;
|
||||
if (negative) input.negative_prompt = negative;
|
||||
if (p.seed != null) input.seed = p.seed;
|
||||
return imageOp("sd-img2img", "RUNPOD_SD_IMG2IMG_URL", input);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #1 YOLO detection → SDK DetectedObject shape (box as fractions 0..1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function rpDetect(
|
||||
imageB64: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<RunpodDetection[]> {
|
||||
const output = await runpodCall<unknown>({
|
||||
name: "yolo-detect",
|
||||
url: endpointUrl("RUNPOD_YOLO_URL"),
|
||||
input: { image: imageB64, task: "detect" },
|
||||
});
|
||||
return normalizeDetections(output, width, height);
|
||||
}
|
||||
|
||||
function extractDetectionArray(output: unknown): unknown[] {
|
||||
if (Array.isArray(output)) return output;
|
||||
if (output && typeof output === "object") {
|
||||
const o = output as Record<string, unknown>;
|
||||
for (const key of ["detections", "predictions", "objects", "results", "boxes"]) {
|
||||
if (Array.isArray(o[key])) return o[key] as unknown[];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** First non-empty STRING among the args (numbers ignored — a numeric `class` is an index, not a name). */
|
||||
function firstLabel(...vals: unknown[]): string | undefined {
|
||||
for (const v of vals) {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeDetections(output: unknown, width: number, height: number): RunpodDetection[] {
|
||||
const out: RunpodDetection[] = [];
|
||||
for (const raw of extractDetectionArray(output)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const o = raw as Record<string, unknown>;
|
||||
// Prefer a human-readable name (ultralytics tojson puts the string in `name`
|
||||
// and a numeric index in `class`); fall back to class_<id>. Using firstLabel
|
||||
// (not `??`) also means an explicit empty-string label doesn't get kept + dropped.
|
||||
const classId = o.class_id ?? (typeof o.class === "number" ? o.class : undefined);
|
||||
const label = (
|
||||
firstLabel(o.label, o.name, o.class_name, typeof o.class === "string" ? o.class : undefined) ??
|
||||
(classId != null ? `class_${classId}` : "object")
|
||||
).toLowerCase();
|
||||
const confidence = Number(o.confidence ?? o.score ?? o.conf ?? 0) || 0;
|
||||
const box = normalizeBox(o, width, height);
|
||||
if (box && label) out.push({ label, confidence, box });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function asNum4(v: unknown): [number, number, number, number] | null {
|
||||
if (!Array.isArray(v) || v.length < 4) return null;
|
||||
const a = num(v[0]);
|
||||
const b = num(v[1]);
|
||||
const c = num(v[2]);
|
||||
const d = num(v[3]);
|
||||
return a === null || b === null || c === null || d === null ? null : [a, b, c, d];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a detection box to {x, y, width, height} as fractions 0..1 of the
|
||||
* image, from whatever shape the endpoint emits:
|
||||
* - `xyxy: [x1,y1,x2,y2]` (ultralytics) and generic `box: [...]` → corner form
|
||||
* - `xywh: [...]` and COCO `bbox: [x,y,w,h]` → x/y/width/height form
|
||||
* - object `{x1,y1,x2,y2}` / `{left,top,right,bottom}` (ultralytics tojson) → corners
|
||||
* - object `{x,y,width,height}` → x/y/width/height
|
||||
* Pixel values (any component > 1) are divided by the image dims; already-
|
||||
* normalized fractions pass through.
|
||||
*/
|
||||
function normalizeBox(
|
||||
o: Record<string, unknown>,
|
||||
width: number,
|
||||
height: number,
|
||||
): RunpodDetection["box"] | null {
|
||||
const W = width || 1;
|
||||
const H = height || 1;
|
||||
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||
const frac = (x: number, y: number, w: number, h: number): RunpodDetection["box"] => {
|
||||
if (Math.max(Math.abs(x), Math.abs(y), Math.abs(w), Math.abs(h)) > 1) {
|
||||
x /= W;
|
||||
y /= H;
|
||||
w /= W;
|
||||
h /= H;
|
||||
}
|
||||
return { x: clamp01(x), y: clamp01(y), width: clamp01(w), height: clamp01(h) };
|
||||
};
|
||||
const fromXyxy = (x1: number, y1: number, x2: number, y2: number) =>
|
||||
frac(x1, y1, x2 - x1, y2 - y1);
|
||||
|
||||
// 1. Array boxes, interpreted by which key holds them.
|
||||
const xyxyArr = asNum4(o.xyxy);
|
||||
if (xyxyArr) return fromXyxy(xyxyArr[0], xyxyArr[1], xyxyArr[2], xyxyArr[3]);
|
||||
const xywhArr = asNum4(o.xywh) ?? asNum4(o.bbox); // COCO `bbox` is [x,y,w,h]
|
||||
if (xywhArr) return frac(xywhArr[0], xywhArr[1], xywhArr[2], xywhArr[3]);
|
||||
const boxArr = asNum4(o.box); // generic array box → assume corner form
|
||||
if (boxArr) return fromXyxy(boxArr[0], boxArr[1], boxArr[2], boxArr[3]);
|
||||
|
||||
// 2. Object boxes (either nested under `box` or directly on the detection).
|
||||
const src =
|
||||
o.box && typeof o.box === "object" && !Array.isArray(o.box)
|
||||
? (o.box as Record<string, unknown>)
|
||||
: o;
|
||||
const x1 = num(src.x1 ?? src.left);
|
||||
const y1 = num(src.y1 ?? src.top);
|
||||
const x2 = num(src.x2 ?? src.right);
|
||||
const y2 = num(src.y2 ?? src.bottom);
|
||||
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) return fromXyxy(x1, y1, x2, y2);
|
||||
|
||||
const x = num(src.x);
|
||||
const y = num(src.y);
|
||||
const w = num(src.width);
|
||||
const h = num(src.height);
|
||||
if (x !== null && y !== null && w !== null && h !== null) return frac(x, y, w, h);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audio / calibration endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** #2 Camera tilt (DeepSingleImageCalibration). */
|
||||
export async function rpTilt(imageB64: string): Promise<TiltResult> {
|
||||
const o = await runpodCall<Record<string, unknown>>({
|
||||
name: "tilt",
|
||||
url: endpointUrl("RUNPOD_TILT_URL"),
|
||||
input: { image: imageB64 },
|
||||
});
|
||||
return {
|
||||
rollDegrees: Number(o.roll_degrees ?? 0) || 0,
|
||||
pitchDegrees: Number(o.pitch_degrees ?? 0) || 0,
|
||||
fovDegrees: Number(o.fov_degrees ?? 0) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** #3 Voice-to-text (Parakeet). Audio must be WAV 16kHz mono PCM16. */
|
||||
export async function rpTranscribe(
|
||||
audioB64: string,
|
||||
opts?: { language?: string; timestamps?: boolean; punctuation?: boolean },
|
||||
): Promise<TranscriptResult> {
|
||||
const o = await runpodCall<Record<string, unknown>>({
|
||||
name: "transcribe",
|
||||
url: endpointUrl("RUNPOD_STT_URL"),
|
||||
input: { audio: audioB64, task: "transcribe", ...(opts ?? {}) },
|
||||
});
|
||||
const rawSegments = Array.isArray(o.segments) ? o.segments : [];
|
||||
const segments = rawSegments.map((s) => {
|
||||
const seg = (s ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
text: String(seg.text ?? ""),
|
||||
startSec: Number(seg.start_sec ?? 0) || 0,
|
||||
endSec: Number(seg.end_sec ?? 0) || 0,
|
||||
};
|
||||
});
|
||||
return {
|
||||
transcript: String(o.transcript ?? ""),
|
||||
segments: segments.length ? segments : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** #12 Audio noise removal (RNNoise). Audio must be WAV 48kHz mono 16-bit PCM. */
|
||||
export async function rpDenoiseAudio(audioB64: string): Promise<{ audioB64: string }> {
|
||||
const o = await runpodCall<Record<string, unknown>>({
|
||||
name: "audio-denoise",
|
||||
url: endpointUrl("RUNPOD_AUDIO_DENOISE_URL"),
|
||||
input: { audio: audioB64, task: "denoise" },
|
||||
});
|
||||
const a = o.audio ?? o.output ?? "";
|
||||
return { audioB64: stripDataUri(String(a)) };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Request/response types for the RunPod endpoints behind the Smart Gallery.
|
||||
*
|
||||
* PROVENANCE: a faithful port of
|
||||
* `advance-photo-gallery-web-sdk/apps/web/src/lib/runpod/types.ts`.
|
||||
* Keep the two in sync — the RunPod endpoints are shared between the standalone
|
||||
* SDK demo and this CRM.
|
||||
*
|
||||
* SERVER-ONLY. Imported by src/app/api/gallery/ai/* route handlers, never by a
|
||||
* client component (which must not see a RunPod URL or key).
|
||||
*/
|
||||
|
||||
/** Normalized image result returned by every image endpoint (raw base64, no data: prefix). */
|
||||
export interface RunpodImageResult {
|
||||
imageBase64: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/** #9 SD 3.5 masked inpainting (also #11 outpaint, pre-padded). white in mask = regenerate. */
|
||||
export interface InpaintReq {
|
||||
imageB64: string;
|
||||
maskB64: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
strength?: number;
|
||||
guidanceScale?: number;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
/** #10 SD 3.5 general prompt edit (img2img, no mask). */
|
||||
export interface Img2ImgReq {
|
||||
imageB64: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
strength?: number;
|
||||
guidanceScale?: number;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1 YOLO construction-material classifier, normalized to the SDK's
|
||||
* `DetectedObject` shape (box as fractions 0..1 of the image).
|
||||
*/
|
||||
export interface RunpodDetection {
|
||||
label: string;
|
||||
confidence: number;
|
||||
box: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
/** #2 DeepSingleImageCalibration — camera tilt. */
|
||||
export interface TiltResult {
|
||||
rollDegrees: number;
|
||||
pitchDegrees: number;
|
||||
fovDegrees: number;
|
||||
}
|
||||
|
||||
/** #3 Parakeet voice-to-text. */
|
||||
export interface TranscriptSegment {
|
||||
text: string;
|
||||
startSec: number;
|
||||
endSec: number;
|
||||
}
|
||||
export interface TranscriptResult {
|
||||
transcript: string;
|
||||
segments?: TranscriptSegment[];
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Session gate for the Smart Gallery AI routes.
|
||||
*
|
||||
* ---------------------------------------------------------------------------
|
||||
* TRUST MODEL
|
||||
* ---------------------------------------------------------------------------
|
||||
* These routes proxy a paid, rate-limited GPU backend (RunPod) using a secret
|
||||
* held only on this server. An unauthenticated route is therefore not merely an
|
||||
* information-disclosure problem — it is a billable-resource problem: anyone who
|
||||
* can reach the URL can spend the operator's GPU budget, and can use the CRM as
|
||||
* an open relay for arbitrary image/audio processing.
|
||||
*
|
||||
* The upstream SDK demo's routes (apps/web/src/app/api/ai/*) are COMPLETELY
|
||||
* unauthenticated. This module is the fix; every ported route must call it
|
||||
* before doing any work.
|
||||
*
|
||||
* WHO IS TRUSTED
|
||||
* We do not verify a JWT here and we do not hold any signing key. The single
|
||||
* source of truth for "is this caller signed in" is the Shell BFF
|
||||
* (`${BFF_ORIGIN}/api/session/context`), which owns the HttpOnly session cookie.
|
||||
* We forward the caller's raw `cookie` header to it and treat a 200 as proof of
|
||||
* a session. Consequences of that choice, stated explicitly:
|
||||
*
|
||||
* - The BFF is trusted absolutely. If it is compromised or misconfigured to
|
||||
* answer 200 for anonymous callers, these routes are open. BFF_ORIGIN must
|
||||
* therefore only ever point at an origin the operator controls.
|
||||
* - We forward the cookie header verbatim and nothing else. No Authorization
|
||||
* header, no bearer token, and never the RunPod key.
|
||||
* - NOTHING IS CACHED. A cached "yes" would keep a revoked/expired session
|
||||
* alive for the cache lifetime, so every AI request costs one BFF round
|
||||
* trip. That is deliberate: correctness over latency for a spend gate.
|
||||
* - A network failure reaching the BFF returns 503 (fail CLOSED), never 200.
|
||||
* If we cannot prove a session, we do not spend GPU budget.
|
||||
*
|
||||
* DEMO MODE
|
||||
* When the Shell is not configured (`NEXT_PUBLIC_SUPABASE_URL` unset) the CRM
|
||||
* runs on its mock portal and there is no session to check, so we allow the
|
||||
* request and log ONCE at startup-of-first-use. This is the same gate
|
||||
* `isShellConfigured()` uses for mock-vs-real auth elsewhere in the app.
|
||||
* IMPORTANT: never deploy to a public origin with the Shell unconfigured AND a
|
||||
* real RUNPOD_API_KEY present — that combination is an open, billable endpoint.
|
||||
*
|
||||
* WHAT THIS IS NOT
|
||||
* This is authentication only, not authorization. It answers "is there a valid
|
||||
* session", not "may this principal use the gallery". Per-resource policy for
|
||||
* gallery data lives in be-crm behind the `crm.gallery` resource; if these AI
|
||||
* routes ever need the same, check it there rather than re-deriving it here.
|
||||
*/
|
||||
|
||||
export type GallerySession =
|
||||
| { ok: true; principalId?: string }
|
||||
| { ok: false; status: number; error: string };
|
||||
|
||||
/** Mirrors src/lib/appshell.ts — kept local so this stays server-only. */
|
||||
function isShellConfigured(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
}
|
||||
|
||||
let demoModeWarned = false;
|
||||
|
||||
/**
|
||||
* Resolve the caller's session. Returns `{ ok: true }` (optionally with the
|
||||
* principal id, used to key rate limits) or a ready-to-return failure with the
|
||||
* status and message the route should emit.
|
||||
*/
|
||||
export async function requireGallerySession(req: Request): Promise<GallerySession> {
|
||||
if (!isShellConfigured()) {
|
||||
if (!demoModeWarned) {
|
||||
demoModeWarned = true;
|
||||
console.warn(
|
||||
"[gallery-ai] Shell is not configured (NEXT_PUBLIC_SUPABASE_URL unset) — " +
|
||||
"AI routes are UNAUTHENTICATED in demo mode. Do not expose this deployment publicly " +
|
||||
"while RUNPOD_API_KEY is set.",
|
||||
);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const cookie = req.headers.get("cookie");
|
||||
if (!cookie) return { ok: false, status: 401, error: "Not signed in" };
|
||||
|
||||
const origin = (process.env.BFF_ORIGIN ?? "http://localhost:4000").replace(/\/$/, "");
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${origin}/api/session/context`, {
|
||||
headers: { cookie, accept: "application/json" },
|
||||
// Never cache an auth decision — see the trust-model note above.
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch {
|
||||
// Fail closed: we could not prove a session, so we do not spend GPU budget.
|
||||
return { ok: false, status: 503, error: "Session service unavailable" };
|
||||
}
|
||||
|
||||
if (res.status === 401) return { ok: false, status: 401, error: "Not signed in" };
|
||||
if (!res.ok) return { ok: false, status: 503, error: "Session service unavailable" };
|
||||
|
||||
// The principal id is best-effort: it only sharpens the rate-limit key, so a
|
||||
// shape we don't recognize degrades to IP-keyed limiting rather than failing.
|
||||
let principalId: string | undefined;
|
||||
try {
|
||||
const data = (await res.json()) as Record<string, unknown> | null;
|
||||
principalId = pickPrincipalId(data);
|
||||
} catch {
|
||||
/* ignore — see above */
|
||||
}
|
||||
|
||||
return principalId ? { ok: true, principalId } : { ok: true };
|
||||
}
|
||||
|
||||
function pickPrincipalId(data: Record<string, unknown> | null): string | undefined {
|
||||
if (!data) return undefined;
|
||||
const direct = data.userId ?? data.principalId ?? data.sub ?? data.id;
|
||||
if (typeof direct === "string" && direct) return direct;
|
||||
const user = data.user;
|
||||
if (user && typeof user === "object") {
|
||||
const u = user as Record<string, unknown>;
|
||||
const nested = u.id ?? u.userId ?? u.sub;
|
||||
if (typeof nested === "string" && nested) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate-limit key for a request: the authenticated principal when known,
|
||||
* otherwise the first hop of `x-forwarded-for`.
|
||||
*
|
||||
* NOTE the first hop is client-controlled unless a trusted proxy overwrites the
|
||||
* header. It is good enough to throttle honest clients and casual abuse; it is
|
||||
* NOT a security boundary. The session gate above is the security boundary.
|
||||
*/
|
||||
export function rateLimitKey(req: Request, principalId?: string): string {
|
||||
if (principalId) return `u:${principalId}`;
|
||||
const xff = req.headers.get("x-forwarded-for") ?? "";
|
||||
const first = xff.split(",")[0]?.trim();
|
||||
return `ip:${first || "unknown"}`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
// SMTP settings data layer — a tenant's own outbound email server (BYO). Serves the local mock
|
||||
// (Shell not configured) or the live be-crm data door (crm.settings.smtp.*).
|
||||
//
|
||||
// Live contract (be-crm → IIOS BYO credential store):
|
||||
// query crm.settings.smtp.status {} -> { configured, enabled?, hints? }
|
||||
// cmd crm.settings.smtp.configure { host, port, secure, user, pass, fromEmail, fromName? } -> masked status
|
||||
// The password is write-only: sealed in IIOS, never returned — status carries only non-secret hints.
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
|
||||
export interface SmtpCredentials {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
pass: string;
|
||||
fromEmail: string;
|
||||
fromName?: string;
|
||||
}
|
||||
|
||||
export interface SmtpStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
fromEmail?: string;
|
||||
fromName?: string;
|
||||
}
|
||||
|
||||
export interface SmtpSettingsData {
|
||||
live: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
status: SmtpStatus;
|
||||
configure: (input: SmtpCredentials) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { host?: string; port?: number; user?: string; fromEmail?: string; fromName?: string } }
|
||||
|
||||
function toStatus(dto?: StatusDTO | null): SmtpStatus {
|
||||
return {
|
||||
configured: !!dto?.configured,
|
||||
enabled: dto?.enabled ?? false,
|
||||
host: dto?.hints?.host,
|
||||
port: dto?.hints?.port,
|
||||
user: dto?.hints?.user,
|
||||
fromEmail: dto?.hints?.fromEmail,
|
||||
fromName: dto?.hints?.fromName,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- Mock (demo mode) — stores only the non-secret hints ---- */
|
||||
function useMockSmtp(): SmtpSettingsData {
|
||||
const [status, setStatus] = useState<SmtpStatus>({ configured: false, enabled: false });
|
||||
const configure = useCallback(async ({ host, port, user, fromEmail, fromName }: SmtpCredentials) => {
|
||||
setStatus({ configured: true, enabled: true, host, port, user, fromEmail, ...(fromName ? { fromName } : {}) });
|
||||
}, []);
|
||||
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
|
||||
}
|
||||
|
||||
/* ---- Live (be-crm data door) ---- */
|
||||
function useLiveSmtp(): SmtpSettingsData {
|
||||
const { sdk } = useAppShell();
|
||||
const q = useQuery<StatusDTO>("crm.settings.smtp.status", {});
|
||||
const configure = useCallback(async (input: SmtpCredentials) => {
|
||||
await sdk.command("crm.settings.smtp.configure", { ...input });
|
||||
q.refetch();
|
||||
}, [sdk, q]);
|
||||
return {
|
||||
live: true,
|
||||
loading: q.loading,
|
||||
error: q.error ? String(q.error) : null,
|
||||
status: toStatus(q.data),
|
||||
configure,
|
||||
refetch: q.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function useSmtpSettings(): SmtpSettingsData {
|
||||
return SHELL ? useLiveSmtp() : useMockSmtp();
|
||||
}
|
||||
Reference in New Issue
Block a user