@sevenfold/setto-client 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export type { SettoImageProps } from './SettoImage';
15
15
  export { SettoRepeater } from './SettoRepeater';
16
16
  export type { SettoRepeaterProps } from './SettoRepeater';
17
17
  export { SettoForm, FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS, fieldTypeHasOptions, fieldTypeHasPlaceholder, formFieldsPrefix, isFormFieldType, readLocaleFields, settoSubmissionUrl, } from './modules/forms';
18
+ export { SettoChat } from './modules/chat';
18
19
  export type { SettoFormProps, SettoFormClassNames, FormFieldProp, FormSettings, FormSubmissionRow, FormValue, FormValues, ResolvedFormField, SubmissionEnvelope, } from './modules/forms';
19
20
  export { SETTO_MODULES } from './modules';
20
21
  export type { SettoModule } from './modules';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * A public visitor chat mounted once by SettoProvider. The server decides
3
+ * whether it exists for a site; a disabled or unreachable chat draws nothing.
4
+ */
5
+ export declare function SettoChat(): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,3 @@
1
+ import type { SettoModule } from '../module';
2
+ export declare const chatModule: SettoModule;
3
+ export { SettoChat } from './SettoChat';
@@ -1,6 +1,6 @@
1
1
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
- import { useState, useRef, useEffect, useMemo, createContext, useContext, useCallback, useLayoutEffect, useSyncExternalStore, forwardRef, Fragment as Fragment$1, useId, useInsertionEffect } from "react";
3
+ import { useState, useRef, useEffect, useMemo, useId, useInsertionEffect, createContext, useContext, useCallback, useLayoutEffect, useSyncExternalStore, forwardRef, Fragment as Fragment$1 } from "react";
4
4
  import { useTranslation } from "react-i18next";
5
5
  import * as ReactDOM from "react-dom";
6
6
  import { createPortal } from "react-dom";
@@ -118,8 +118,8 @@ function redirectForVisitor(pathname, languages, browserLanguages, stored, prima
118
118
  const base = wanted.toLowerCase().split("-")[0];
119
119
  if (!base) continue;
120
120
  if (base === primary.toLowerCase().split("-")[0]) return null;
121
- const norwegian = ["no", "nb", "nn"];
122
- if (norwegian.includes(base) && norwegian.includes(primary.toLowerCase())) return null;
121
+ const norwegian2 = ["no", "nb", "nn"];
122
+ if (norwegian2.includes(base) && norwegian2.includes(primary.toLowerCase())) return null;
123
123
  const hit = languages.find((tag) => tag.toLowerCase().split("-")[0] === base);
124
124
  if (hit && hit !== primary) return pathForLanguage(pathname, hit, languages, primary);
125
125
  }
@@ -176,7 +176,7 @@ function SettoLanguages({ languages, inline = false }) {
176
176
  "aria-label": `Språk: ${languageName(current)}`,
177
177
  style: inline ? inlineTriggerStyle : floatingTriggerStyle,
178
178
  children: [
179
- /* @__PURE__ */ jsx(LanguagesIcon, {}),
179
+ /* @__PURE__ */ jsx(GlobeIcon, {}),
180
180
  languageName(current)
181
181
  ]
182
182
  }
@@ -212,26 +212,22 @@ function AutoLanguageSwitcher({ languages }) {
212
212
  if (inlineElsewhere || languages.length < 2) return null;
213
213
  return /* @__PURE__ */ jsx(SettoLanguages, { languages });
214
214
  }
215
- function LanguagesIcon() {
215
+ function GlobeIcon() {
216
216
  return /* @__PURE__ */ jsxs(
217
217
  "svg",
218
218
  {
219
- width: "16",
220
- height: "16",
219
+ width: "15",
220
+ height: "15",
221
221
  viewBox: "0 0 24 24",
222
222
  fill: "none",
223
223
  stroke: "currentColor",
224
- strokeWidth: "1.7",
224
+ strokeWidth: "1.8",
225
225
  strokeLinecap: "round",
226
226
  strokeLinejoin: "round",
227
227
  "aria-hidden": "true",
228
228
  children: [
229
- /* @__PURE__ */ jsx("path", { d: "M2.2 17.8 6.5 6.8l4.3 11" }),
230
- /* @__PURE__ */ jsx("path", { d: "M3.7 14.2h5.6" }),
231
- /* @__PURE__ */ jsx("path", { d: "M17.3 3.4 16.2 5.6" }),
232
- /* @__PURE__ */ jsx("path", { d: "M12.8 8.2h9" }),
233
- /* @__PURE__ */ jsx("path", { d: "M13.6 18.8 21 10.9" }),
234
- /* @__PURE__ */ jsx("path", { d: "M14.7 10.9l7.1 7.9" })
229
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "9" }),
230
+ /* @__PURE__ */ jsx("path", { d: "M3 12h18M12 3c2.4 2.6 3.6 5.6 3.6 9s-1.2 6.4-3.6 9c-2.4-2.6-3.6-5.6-3.6-9S9.6 5.6 12 3z" })
235
231
  ]
236
232
  }
237
233
  );
@@ -22036,6 +22032,234 @@ function EditModeShell({ children, themeStore, active }) {
22036
22032
  active && sectionToolbarRoot && themeStore && SectionTools ? createPortal(/* @__PURE__ */ jsx(SectionTools, {}), sectionToolbarRoot) : null
22037
22033
  ] });
22038
22034
  }
22035
+ const VISITOR_KEY = "setto-chat-visitor";
22036
+ function visitorId() {
22037
+ try {
22038
+ const stored = window.localStorage.getItem(VISITOR_KEY);
22039
+ if (stored && /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(stored)) return stored;
22040
+ const id = crypto.randomUUID();
22041
+ window.localStorage.setItem(VISITOR_KEY, id);
22042
+ return id;
22043
+ } catch {
22044
+ return crypto.randomUUID();
22045
+ }
22046
+ }
22047
+ function norwegian(locale) {
22048
+ return locale.toLowerCase().startsWith("no");
22049
+ }
22050
+ function intro(locale, name) {
22051
+ return norwegian(locale) ? `Hei! Jeg er Barista, ${name ? `${name}s` : "nettstedets"} AI-assistent. Jeg svarer ut fra det teamet har delt her, og hjelper deg videre til dem når det trengs.` : `Hello! I’m Barista, the AI assistant for ${name ?? "this website"}. I answer from what the team has shared here and help you reach them when needed.`;
22052
+ }
22053
+ function contactTeam() {
22054
+ const link = document.querySelector(
22055
+ 'a[href*="kontakt" i], a[href*="contact" i], a[href^="mailto:"]'
22056
+ );
22057
+ if (link) link.click();
22058
+ else window.location.hash = "kontakt";
22059
+ }
22060
+ function SettoChat() {
22061
+ const { config } = useSetto();
22062
+ const { i18n } = useTranslation();
22063
+ const [settings, setSettings] = useState(null);
22064
+ const [open, setOpen] = useState(false);
22065
+ const [messages, setMessages] = useState([]);
22066
+ const [draft, setDraft] = useState("");
22067
+ const [sending, setSending] = useState(false);
22068
+ const conversationId = useRef(null);
22069
+ const visitor = useRef(null);
22070
+ const logRef = useRef(null);
22071
+ const formId = useId();
22072
+ useChatStyles();
22073
+ useEffect(() => {
22074
+ if (window.location.pathname.startsWith("/setto")) return;
22075
+ let cancelled = false;
22076
+ fetch(`${config.apiUrl}/sites/${encodeURIComponent(config.siteId)}/chat/settings/public`, {
22077
+ headers: { Accept: "application/json" }
22078
+ }).then(async (response) => response.ok ? await response.json() : null).then((next) => {
22079
+ if (!cancelled && next?.enabled) setSettings(next);
22080
+ }).catch(() => {
22081
+ });
22082
+ return () => {
22083
+ cancelled = true;
22084
+ };
22085
+ }, [config.apiUrl, config.siteId]);
22086
+ useEffect(() => {
22087
+ if (!open || messages.length > 0 || !settings) return;
22088
+ setMessages([{ role: "assistant", content: intro(i18n.language, settings.displayName) }]);
22089
+ }, [open, messages.length, settings, i18n.language]);
22090
+ useEffect(() => {
22091
+ if (open) logRef.current?.scrollTo({ top: logRef.current.scrollHeight });
22092
+ }, [messages, open]);
22093
+ if (!settings || window.location.pathname.startsWith("/setto")) return null;
22094
+ const locale = i18n.language || "no";
22095
+ const labels = norwegian(locale) ? {
22096
+ open: `Snakk med ${settings.displayName}`,
22097
+ close: "Lukk chatten",
22098
+ title: "Spør Barista",
22099
+ disclosure: "AI-assistent · svarer fra nettstedets godkjente informasjon",
22100
+ placeholder: "Skriv spørsmålet ditt …",
22101
+ send: "Send",
22102
+ sending: "Svarer …",
22103
+ contact: "Kontakt teamet",
22104
+ failed: "Jeg får ikke svart akkurat nå. Kontakt gjerne teamet direkte."
22105
+ } : {
22106
+ open: `Chat with ${settings.displayName}`,
22107
+ close: "Close chat",
22108
+ title: "Ask Barista",
22109
+ disclosure: "AI assistant · answers from this website’s approved information",
22110
+ placeholder: "Write your question …",
22111
+ send: "Send",
22112
+ sending: "Replying …",
22113
+ contact: "Contact the team",
22114
+ failed: "I cannot reply right now. Please contact the team directly."
22115
+ };
22116
+ const submit = async (event) => {
22117
+ event.preventDefault();
22118
+ const message = draft.trim();
22119
+ if (!message || sending) return;
22120
+ setDraft("");
22121
+ setSending(true);
22122
+ setMessages((previous) => [...previous, { role: "visitor", content: message }, { role: "assistant", content: "" }]);
22123
+ visitor.current ??= visitorId();
22124
+ try {
22125
+ const response = await fetch(
22126
+ `${config.apiUrl}/sites/${encodeURIComponent(config.siteId)}/chat/messages`,
22127
+ {
22128
+ method: "POST",
22129
+ headers: { "Content-Type": "application/json", Accept: "text/plain, application/json" },
22130
+ body: JSON.stringify({
22131
+ conversationId: conversationId.current ?? void 0,
22132
+ visitorId: visitor.current,
22133
+ locale,
22134
+ page: window.location.href,
22135
+ message
22136
+ })
22137
+ }
22138
+ );
22139
+ const headerConversation = response.headers.get("X-Setto-Conversation");
22140
+ if (headerConversation) conversationId.current = headerConversation;
22141
+ const contentType = response.headers.get("content-type") ?? "";
22142
+ if (!response.ok) throw new Error(`chat_failed_${response.status}`);
22143
+ if (contentType.includes("application/json")) {
22144
+ const body = await response.json();
22145
+ if (body.conversationId) conversationId.current = body.conversationId;
22146
+ setMessages((previous) => replaceLastAssistant(previous, body.answer || labels.failed));
22147
+ return;
22148
+ }
22149
+ const reader = response.body?.getReader();
22150
+ if (!reader) throw new Error("chat_missing_stream");
22151
+ const decoder = new TextDecoder();
22152
+ let answer = "";
22153
+ while (true) {
22154
+ const { done, value } = await reader.read();
22155
+ if (done) break;
22156
+ answer += decoder.decode(value, { stream: true });
22157
+ setMessages((previous) => replaceLastAssistant(previous, answer));
22158
+ }
22159
+ answer += decoder.decode();
22160
+ if (!answer.trim()) setMessages((previous) => replaceLastAssistant(previous, labels.failed));
22161
+ } catch {
22162
+ setMessages((previous) => replaceLastAssistant(previous, labels.failed));
22163
+ } finally {
22164
+ setSending(false);
22165
+ }
22166
+ };
22167
+ return /* @__PURE__ */ jsxs("aside", { className: "setto-chat", "aria-label": labels.title, "data-setto-ui": true, children: [
22168
+ open ? /* @__PURE__ */ jsxs("section", { className: "setto-chat__panel", role: "dialog", "aria-modal": "false", "aria-labelledby": `${formId}-title`, children: [
22169
+ /* @__PURE__ */ jsxs("header", { className: "setto-chat__header", children: [
22170
+ /* @__PURE__ */ jsxs("div", { children: [
22171
+ /* @__PURE__ */ jsx("p", { className: "setto-chat__eyebrow", children: "BARISTA" }),
22172
+ /* @__PURE__ */ jsx("h2", { id: `${formId}-title`, children: labels.title }),
22173
+ /* @__PURE__ */ jsx("p", { children: labels.disclosure })
22174
+ ] }),
22175
+ /* @__PURE__ */ jsx("button", { className: "setto-chat__close", type: "button", onClick: () => setOpen(false), "aria-label": labels.close, children: "×" })
22176
+ ] }),
22177
+ /* @__PURE__ */ jsx("div", { className: "setto-chat__log", ref: logRef, "aria-live": "polite", children: messages.map((item, index) => /* @__PURE__ */ jsx("div", { className: `setto-chat__message setto-chat__message--${item.role}`, children: item.content || /* @__PURE__ */ jsx("span", { className: "setto-chat__typing", children: "···" }) }, `${item.role}-${index}`)) }),
22178
+ /* @__PURE__ */ jsx("div", { className: "setto-chat__contact", children: /* @__PURE__ */ jsxs("button", { type: "button", onClick: contactTeam, children: [
22179
+ labels.contact,
22180
+ " ",
22181
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "↗" })
22182
+ ] }) }),
22183
+ /* @__PURE__ */ jsxs("form", { className: "setto-chat__composer", onSubmit: submit, children: [
22184
+ /* @__PURE__ */ jsx("label", { className: "setto-chat__sr", htmlFor: `${formId}-input`, children: labels.placeholder }),
22185
+ /* @__PURE__ */ jsx(
22186
+ "textarea",
22187
+ {
22188
+ id: `${formId}-input`,
22189
+ value: draft,
22190
+ rows: 1,
22191
+ maxLength: 1500,
22192
+ onChange: (event) => setDraft(event.target.value),
22193
+ placeholder: labels.placeholder,
22194
+ disabled: sending,
22195
+ onKeyDown: (event) => {
22196
+ if (event.key === "Enter" && !event.shiftKey) {
22197
+ event.preventDefault();
22198
+ event.currentTarget.form?.requestSubmit();
22199
+ }
22200
+ }
22201
+ }
22202
+ ),
22203
+ /* @__PURE__ */ jsx("button", { type: "submit", disabled: !draft.trim() || sending, children: sending ? labels.sending : labels.send })
22204
+ ] })
22205
+ ] }) : null,
22206
+ /* @__PURE__ */ jsxs("button", { className: "setto-chat__launcher", type: "button", onClick: () => setOpen(true), "aria-expanded": open, children: [
22207
+ /* @__PURE__ */ jsx("span", { className: "setto-chat__launcher-mark", "aria-hidden": "true", children: "✦" }),
22208
+ /* @__PURE__ */ jsx("span", { children: labels.open })
22209
+ ] })
22210
+ ] });
22211
+ }
22212
+ function replaceLastAssistant(messages, content) {
22213
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
22214
+ if (messages[index]?.role === "assistant") {
22215
+ const next = [...messages];
22216
+ next[index] = { role: "assistant", content };
22217
+ return next;
22218
+ }
22219
+ }
22220
+ return [...messages, { role: "assistant", content }];
22221
+ }
22222
+ function useChatStyles() {
22223
+ useInsertionEffect(() => {
22224
+ const style = document.createElement("style");
22225
+ style.dataset.settoChat = "";
22226
+ style.textContent = `
22227
+ .setto-chat { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 2147483000; color: #231d17; font-family: Archivo, ui-sans-serif, sans-serif; }
22228
+ .setto-chat *, .setto-chat *::before, .setto-chat *::after { box-sizing: border-box; }
22229
+ .setto-chat__launcher { border: 0; min-height: 54px; max-width: min(290px, calc(100vw - 32px)); border-radius: 999px; padding: 8px 18px 8px 9px; display: flex; align-items: center; gap: 10px; background: #251d16; color: #fff9ef; box-shadow: 0 14px 40px rgba(35,29,23,.28); font: 600 14px/1 Archivo, ui-sans-serif, sans-serif; cursor: pointer; transition: transform .18s ease, box-shadow .18s ease; }
22230
+ .setto-chat__launcher:hover { transform: translateY(-2px); box-shadow: 0 18px 46px rgba(35,29,23,.36); }
22231
+ .setto-chat__launcher-mark { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; background: #df6540; color: #fff8eb; font-size: 18px; }
22232
+ .setto-chat__panel { width: min(390px, calc(100vw - 32px)); height: min(590px, calc(100dvh - 104px)); margin-bottom: 12px; overflow: hidden; display: grid; grid-template-rows: auto 1fr auto auto; border: 1px solid rgba(37,29,22,.15); border-radius: 20px; background: #fffaf1; box-shadow: 0 22px 70px rgba(35,29,23,.3); }
22233
+ .setto-chat__header { padding: 20px 20px 16px; display: flex; justify-content: space-between; gap: 14px; border-bottom: 1px solid #eadfce; background: radial-gradient(circle at 0 0, #f4d4a9, transparent 52%), #fffaf1; }
22234
+ .setto-chat__eyebrow { margin: 0 0 4px; color: #b54628; font-size: 10px; font-weight: 700; letter-spacing: .14em; }
22235
+ .setto-chat__header h2 { margin: 0; color: #211a14; font-family: "Instrument Serif", Georgia, serif; font-size: 29px; font-weight: 400; line-height: 1; }
22236
+ .setto-chat__header p:not(.setto-chat__eyebrow) { margin: 7px 0 0; max-width: 280px; color: #70665a; font-size: 12px; line-height: 1.35; }
22237
+ .setto-chat__close { width: 32px; height: 32px; border: 0; border-radius: 50%; background: rgba(64,47,31,.07); color: #4f453a; font: 300 28px/27px Georgia, serif; cursor: pointer; }
22238
+ .setto-chat__log { min-height: 0; overflow: auto; padding: 18px; display: flex; flex-direction: column; gap: 10px; scroll-behavior: smooth; }
22239
+ .setto-chat__message { max-width: 88%; padding: 11px 13px; border-radius: 14px; font-size: 14px; line-height: 1.48; white-space: pre-wrap; overflow-wrap: anywhere; }
22240
+ .setto-chat__message--assistant { align-self: flex-start; background: #efe7da; color: #302820; border-bottom-left-radius: 3px; }
22241
+ .setto-chat__message--visitor { align-self: flex-end; background: #2b221b; color: #fffaf1; border-bottom-right-radius: 3px; }
22242
+ .setto-chat__typing { display: inline-block; min-width: 24px; animation: setto-chat-pulse 1.1s ease-in-out infinite; letter-spacing: 2px; }
22243
+ @keyframes setto-chat-pulse { 50% { opacity: .3; } }
22244
+ .setto-chat__contact { padding: 0 18px 12px; }
22245
+ .setto-chat__contact button { border: 0; padding: 0; background: transparent; color: #b54628; font: 600 12px/1.4 Archivo, ui-sans-serif, sans-serif; cursor: pointer; }
22246
+ .setto-chat__composer { padding: 12px; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; border-top: 1px solid #eadfce; background: #fffdf8; }
22247
+ .setto-chat__composer textarea { width: 100%; max-height: 100px; resize: none; border: 1px solid #d8ccba; border-radius: 10px; background: #fffaf1; color: #231d17; padding: 10px; font: 14px/1.35 Archivo, ui-sans-serif, sans-serif; outline: none; }
22248
+ .setto-chat__composer textarea:focus { border-color: #b54628; box-shadow: 0 0 0 3px rgba(181,70,40,.12); }
22249
+ .setto-chat__composer button { min-height: 40px; border: 0; border-radius: 9px; padding: 0 13px; background: #b54628; color: #fffaf1; font: 600 13px Archivo, ui-sans-serif, sans-serif; cursor: pointer; }
22250
+ .setto-chat__composer button:disabled { opacity: .5; cursor: default; }
22251
+ .setto-chat__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
22252
+ @media (max-width: 480px) { .setto-chat { right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); } .setto-chat__panel { width: calc(100vw - 24px); height: min(610px, calc(100dvh - 88px)); } .setto-chat__launcher { min-height: 50px; } }
22253
+ @media (prefers-reduced-motion: reduce) { .setto-chat *, .setto-chat *::before, .setto-chat *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
22254
+ `;
22255
+ document.head.appendChild(style);
22256
+ return () => style.remove();
22257
+ }, []);
22258
+ }
22259
+ const chatModule = {
22260
+ id: "chat",
22261
+ label: "Chat"
22262
+ };
22039
22263
  const SettoContext = createContext(null);
22040
22264
  function SettoProvider({ config, children }) {
22041
22265
  const supabase = useMemo(() => getSupabase(config.supabase), [config.supabase]);
@@ -22145,6 +22369,7 @@ function SettoProvider({ config, children }) {
22145
22369
  return /* @__PURE__ */ jsx(SettoContext.Provider, { value, children: /* @__PURE__ */ jsxs(Fragment, { children: [
22146
22370
  /* @__PURE__ */ jsx(EditModeShell, { active: editShellActive, themeStore: value.themeStore, children }),
22147
22371
  /* @__PURE__ */ jsx(SettoChrome, {}),
22372
+ /* @__PURE__ */ jsx(SettoChat, {}),
22148
22373
  /* @__PURE__ */ jsx(AutoLanguageSwitcher, { languages })
22149
22374
  ] }) });
22150
22375
  }
@@ -23913,7 +24138,7 @@ const domainModule = {
23913
24138
  id: "domain",
23914
24139
  label: "Domene"
23915
24140
  };
23916
- const SETTO_MODULES = [formsModule, domainModule];
24141
+ const SETTO_MODULES = [formsModule, chatModule, domainModule];
23917
24142
  function SettoVideo({ srcKey, posterKey, title, className }) {
23918
24143
  const { t, i18n } = useTranslation();
23919
24144
  const { store } = useSetto();
@@ -24953,6 +25178,7 @@ export {
24953
25178
  SettoAnimation,
24954
25179
  SettoBlock,
24955
25180
  SettoBlockTree,
25181
+ SettoChat,
24956
25182
  SettoChrome,
24957
25183
  SettoContext,
24958
25184
  SettoForm,