Placeholders: {"{vin}"} · {"{stock}"} · {"{dealId}"} — the DMS fills them in and URL-encodes the value.
Open the page once inside the vendor site, copy its address, swap the VIN / stock # / deal # for the placeholder, paste it here.
Leave a field blank to open the vendor home page instead.
{step(1, "Sign in once", "Click Sign in ▸ on a card. The vendor opens in its own tab — use your normal dealer login.")}
{step(2, "Keep the tab", "Leave that tab open next to the DMS. The DMS remembers it by name and re-focuses it every time.")}
{step(3, "Every VIN / deal opens there", "Carfax on a unit, vAuto on a trade, RouteOne on a deal — each button lands straight on the page, already signed in.")}
Plain truth: Carfax, vAuto, RouteOne and the rest block being embedded inside another website, and their data APIs require a signed partner contract.
So these launchers use your own login in a dedicated tab and store no passwords or tokens — your session lives only in the vendor's tab.
"Signed in" here means you signed in to the vendor's website; it is not an API connection. Server-to-server API integrations are separate — an administrator manages them in Settings → Integrations, and their real status is shown above. If a launch opens a fresh tab instead of reusing the old one, the browser closed the link between them — just sign in again in the new tab.
Vendor sites — open with your own login{connected} of {apps.length} signed in · each opens in tab k2b-app-<app>
{go && }
{apps.map(a => )}
);
};
// ── Server API integrations: shared client helper + read-only status ─────
// Every call goes to the DMS server (/api/integrations/...). Secrets are never
// sent back to the browser — admin screens only see "stored ✓" flags.
const k2bIntApi = async (path, method, body) => {
try {
if (typeof apiFetch === "function") {
const d = await apiFetch("/integrations" + path, { method: method || "GET", body });
return { ok: true, status: 200, data: d || {} };
}
let tok = null; try { tok = sessionStorage.getItem("jbrews-auth-token") || localStorage.getItem("jbrews-auth-token"); } catch (e) {}
const r = await fetch(location.origin + "/api/integrations" + path, { method: method || "GET", credentials: "omit",
headers: Object.assign({ "Content-Type": "application/json" }, tok ? { Authorization: "Bearer " + tok } : {}, tok && window.K2BStore ? window.K2BStore.stamp({}) : {}), body: body != null ? JSON.stringify(body) : undefined }); // RC35 (brd): + X-K2B-Store / X-K2B-Protocol
let d = {}; try { d = await r.json(); } catch (e) {}
return { ok: r.ok, status: r.status, data: d, error: r.ok ? "" : (d.error || (r.status + " " + r.statusText)) };
} catch (e) {
return { ok: false, status: e.status || 0, data: e.body || {}, error: (e.body && e.body.error) || e.message || "Server unreachable" };
}
};
const K2B_INT_ORDER = ["twilio", "email", "routeone", "carfax", "vauto", "gm", "anthropic"];
const k2bIntFmt = (d) => d ? new Date(d).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "";
// status → chip. "Connected" ONLY for status === "connected" (set by the server after a real provider test).
const k2bIntChip = (p, opts) => {
const partner = p.kind === "partner" || ["routeone", "carfax", "vauto", "gm"].includes(p.id);
const st = p.status || "not_configured";
if (st === "connected") return { bg: "#E6F7EC", fg: "#0E7A37", txt: "● Connected" + (p.lastOkAt ? " (tested " + k2bIntFmt(p.lastOkAt) + ")" : "") };
if (st === "error") return { bg: "#FDECEA", fg: "#B23A2E", txt: "✕ Error" + (opts && opts.detail && p.lastError ? ": " + p.lastError : " — not connected") };
if (st === "mock") return { bg: "#F1EAFB", fg: "#6B3FA0", txt: "◌ Mock mode — not connected" };
if (st === "ready") return partner ? { bg: "#FBF3E0", fg: "#9A6700", txt: "◐ Ready — waiting on provider" } : { bg: "#FBF3E0", fg: "#9A6700", txt: "◐ Saved — not tested" };
return { bg: "#EEF0F3", fg: "#666", txt: "○ Not configured" };
};
const K2BIntChip = ({ p, detail }) => {
if (p.id === "anthropic") return ;
const c = k2bIntChip(p, { detail });
return {c.txt};
};
// ── AI Import (Claude page reading): ONE shared status ──────────────────────
// The Upload tool banner, the "AI Import settings" window and the Settings > Integrations card
// all read this store, so they can never disagree. It merges two server answers:
// GET /api/ai-import/status (canUse, reason, limits, pricing, keyStored/consent/enabled/connected)
// GET /api/integrations/status-summary (the provider row: connected / ready / error)
// Any non-200 from the first one (the role gate answers Sales with a plain 403) means "not available to your role".
const K2BAiStatus = (() => {
let snap = { loading: true, ai: null, aiErr: "", httpStatus: 0, prov: null, at: 0 };
const subs = new Set();
let inflight = null;
const emit = () => subs.forEach(f => { try { f(snap); } catch (e) {} });
const refresh = () => {
if (inflight) return inflight;
inflight = (async () => {
let ai = null, aiErr = "", httpStatus = 0, prov = null;
try { ai = await apiFetch("/ai-import/status"); }
catch (e) { httpStatus = (e && e.status) || 0; aiErr = (e && e.body && e.body.error) || (e && e.message) || "The server did not answer."; }
try { const r = await k2bIntApi("/status-summary"); if (r.ok) prov = ((r.data && r.data.providers) || []).find(p => p.id === "anthropic") || null; } catch (e) {}
snap = { loading: false, ai, aiErr, httpStatus, prov, at: Date.now() };
})().then(() => { inflight = null; emit(); }, () => { inflight = null; emit(); });
return inflight;
};
const MODEL_NAMES = { "claude-opus-5-5": "Claude Opus 5.5", "claude-sonnet-5-5": "Claude Sonnet 5.5", "claude-fable-5-1": "Claude Fable 5.1" };
const isAdminNow = () => { try { return !!(window.DMS && window.DMS.isAdmin && window.DMS.isAdmin()); } catch (e) { return false; } };
// snapshot -> what every screen shows. tone: ok | warn | bad | off
const view = (s) => {
s = s || snap;
const ai = s.ai, prov = s.prov;
const admin = ai ? !!ai.canConfigure : isAdminNow();
const out = { loading: !!s.loading, admin, canUse: false, key: "loading", tone: "off", chip: "… checking", headline: "Checking whether AI reading is available…", next: "", model: "", modelName: "", pricing: null, limits: null, reason: "", ai, prov };
if (s.loading) return out;
if (!ai) {
if (s.httpStatus === 403 || s.httpStatus === 401) return Object.assign(out, { key: "role", tone: "off", chip: "○ Not available to your role", headline: "AI reading is limited to Administrators, Office Managers and Accounting.", next: "Ask an administrator to read these pages for you, or sign in with one of those roles.", reason: s.aiErr });
return Object.assign(out, { key: "unreachable", tone: "bad", chip: "✕ Status unavailable", headline: "Could not reach the server to check AI reading.", next: "Check the connection and press Refresh. " + (s.aiErr ? "(" + s.aiErr + ")" : ""), reason: s.aiErr });
}
out.model = ai.model || ""; out.modelName = MODEL_NAMES[ai.model] || ai.model || ""; out.pricing = ai.pricing || null; out.limits = ai.limits || null; out.reason = ai.reason || "";
const where = "Settings > Integrations (API) > AI Import";
// step = what an administrator does (a sentence that starts with a verb); next = the same, worded for where the person is reading it
const adm = (step, ask) => ({ step: admin ? step : "", next: admin ? "Open AI Import settings and " + step.charAt(0).toLowerCase() + step.slice(1) : ask });
if (/limited to Admin/i.test(ai.reason || "")) return Object.assign(out, { key: "role", tone: "off", chip: "○ Not available to your role", headline: "AI reading is limited to Administrators, Office Managers and Accounting.", next: "Ask an administrator to read these pages for you, or sign in with one of those roles." });
if (ai.available === false) return Object.assign(out, { key: "server", tone: "bad", chip: "✕ Server update incomplete", headline: "The AI reading library is missing on the server.", step: admin ? "Run the AI Import server update again (see the install notes), then press Refresh." : "", next: admin ? "Run the AI Import server update again (see the install notes), then press Refresh." : "Tell an administrator: the AI Import server update is incomplete." });
if (!ai.keyStored) return Object.assign(out, { key: "notset", tone: "off", chip: "○ Not set up", headline: "AI reading is not set up yet." }, adm("Paste your Anthropic key, tick the consent box, press Save and then Test connection.", "Ask an administrator to set it up (" + where + ")."));
if (!ai.consent) return Object.assign(out, { key: "consent", tone: "warn", chip: "◐ Needs consent", headline: "The key is saved, but the consent box is not ticked." }, adm("Tick the box that confirms pages may be sent to Anthropic to be read, then press Save.", "Ask an administrator to tick the consent box (" + where + ")."));
if (prov && prov.status === "error") return Object.assign(out, { key: "error", tone: "bad", chip: "✕ Error — not connected", headline: "AI reading stopped working: the last connection test or page read failed." }, adm("Press Test connection to see why (usually a wrong key, or no credit on the Anthropic account).", "Tell an administrator. They can see the reason in " + where + "."));
if (!ai.connected) return Object.assign(out, { key: "untested", tone: "warn", chip: "◐ Saved — not tested", headline: "The key is saved but has not been tested yet." }, adm("Press Test connection.", "Ask an administrator to press Test connection (" + where + ")."));
if (!ai.enabled) return Object.assign(out, { key: "off", tone: "warn", chip: "◐ Connected — switched off", headline: "AI reading is connected but switched off." }, adm("Turn AI reading on (the box at the top right of the card).", "Ask an administrator to turn it on (" + where + ")."));
if (!ai.canUse) return Object.assign(out, { key: "limit", tone: "warn", chip: "◐ Daily limit reached", headline: ai.reason || "The daily page limit has been reached." }, adm("It resets at midnight, or raise \"Pages allowed per day\" and press Save.", "It resets at midnight, or ask an administrator to raise the limit."));
return Object.assign(out, { key: "ready", tone: "ok", canUse: true, chip: "● Connected", headline: "AI reading is on" + (out.modelName ? " (" + out.modelName + ")" : "") + ".", next: "", step: "" });
};
const toneCss = { ok: { bg: "#E6F7EC", fg: "#0E7A37" }, warn: { bg: "#FBF3E0", fg: "#9A6700" }, bad: { bg: "#FDECEA", fg: "#B23A2E" }, off: { bg: "#EEF0F3", fg: "#666" } };
return {
get: () => snap, refresh, view, toneCss, MODEL_NAMES,
subscribe: (fn) => { subs.add(fn); return () => subs.delete(fn); },
};
})();
// Re-render on every status change; load once when first used (and again when the data is older than 20 s).
const useK2BAiStatus = () => {
const [, tick] = React.useReducer(n => n + 1, 0);
React.useEffect(() => {
const off = K2BAiStatus.subscribe(tick);
const s = K2BAiStatus.get();
if (s.loading || Date.now() - s.at > 20000) K2BAiStatus.refresh();
return off;
}, []);
return K2BAiStatus.view();
};
const K2BAiChip = ({ v, style }) => {
const cur = useK2BAiStatus();
const x = v || cur; const c = K2BAiStatus.toneCss[x.tone] || K2BAiStatus.toneCss.off;
return {x.chip};
};
// The AI Import provider's chip: the shared status, except while it loads or when this role cannot use AI reading at all
// (then the provider's own row is shown, so the dealership's setup is still visible).
const K2BAiIntChip = ({ p, detail }) => {
const v = useK2BAiStatus();
if (v.key === "role" || v.key === "loading") { const c = k2bIntChip(p, { detail }); return {c.txt}; }
return ;
};
// Read-only status strip for everyone (no config, no errors, no secrets).
const IntegrationStatusStrip = ({ go, admin }) => {
const [st, setSt] = React.useState({ loading: true });
const load = React.useCallback(async () => {
const r = await k2bIntApi("/status-summary");
setSt(r.ok ? { loading: false, providers: r.data.providers || [], mock: !!r.data.mock } : { loading: false, error: r.error });
}, []);
React.useEffect(() => { load(); }, [load]);
const live = (st.providers || []).filter(p => p.status === "connected" && p.enabled).length;
return (
API integrations (server)
{st.loading ? "checking…" : st.error ? "status unavailable — " + st.error : live + " genuinely connected · the rest are not live"}
{admin && go && }
{st.mock &&
Server mock mode is on (INTEGRATIONS_MOCK=1): RouteOne / CARFAX / vAuto / e-sign notify screens may show simulated results. Simulated results are never "connected".
}
{(st.providers || []).slice().sort((a, b) => K2B_INT_ORDER.indexOf(a.id) - K2B_INT_ORDER.indexOf(b.id)).map(p => (