@sevenfold/setto-client 0.26.0 → 0.28.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.
@@ -0,0 +1,27 @@
1
+ import { type CSSProperties } from 'react';
2
+ export interface SettoMapProps {
3
+ /**
4
+ * i18n key prefix holding `lat`, `lng`, optional `zoom` (1–19, default 15)
5
+ * and optional `title`. A static string literal, like every other key.
6
+ */
7
+ k: string;
8
+ className?: string;
9
+ style?: CSSProperties;
10
+ /** The iframe's loading strategy. Default `lazy`. */
11
+ loading?: 'lazy' | 'eager';
12
+ }
13
+ /**
14
+ * A map whose location is content.
15
+ *
16
+ * Rendered as an OpenStreetMap embed with a marker on the point, because a
17
+ * hand-written embed with a guessed pin in the middle of a fjord is what the
18
+ * build agent produced without it, and nobody could move that pin. Here the
19
+ * point lives in the locale, so it publishes like text, and in edit mode the
20
+ * tools Setto serves (`map-tools`) sit over the map to move it.
21
+ *
22
+ * The wrapper carries the site's classes in both modes and the iframe fills
23
+ * it, so a map sized with `h-72 w-full` is the same box whether or not anybody
24
+ * is editing. Without a usable location the wrapper renders empty — no iframe
25
+ * pointing at 0,0 — and still takes the tools, so the owner can set one.
26
+ */
27
+ export declare function SettoMap({ k, className, style, loading }: SettoMapProps): import("react/jsx-runtime").JSX.Element;
package/dist/index.d.ts CHANGED
@@ -21,6 +21,9 @@ export { SETTO_MODULES } from './modules';
21
21
  export type { SettoModule } from './modules';
22
22
  export { SettoVideo } from './SettoVideo';
23
23
  export type { SettoVideoProps } from './SettoVideo';
24
+ export { SettoMap } from './SettoMap';
25
+ export type { SettoMapProps } from './SettoMap';
26
+ export type { MapLocation, MapToolsProps, MapToolsComponent } from './map-tools';
24
27
  export { SettoAnimation } from './SettoAnimation';
25
28
  export type { SettoAnimationProps } from './SettoAnimation';
26
29
  export { useSectionTheme } from './use-section-theme';
@@ -0,0 +1,80 @@
1
+ import type { MapLocation } from '../map-tools';
2
+ import type { I18nStore } from './i18n-store';
3
+ /**
4
+ * Where a `SettoMap` points, and how that becomes an OpenStreetMap embed.
5
+ *
6
+ * The location is content, stored under the map's key in every locale file:
7
+ * `{ lat, lng, zoom, title }`, all strings, because every other value in those
8
+ * files is. Pure on purpose — the component, the editor's tools and the tests
9
+ * all agree on one reading of what the build agent wrote.
10
+ */
11
+ /** What a locale file holds under a map's key. Every field may be missing. */
12
+ export interface RawMapContent {
13
+ lat?: unknown;
14
+ lng?: unknown;
15
+ zoom?: unknown;
16
+ }
17
+ export declare const MAP_MIN_ZOOM = 1;
18
+ export declare const MAP_MAX_ZOOM = 19;
19
+ export declare const MAP_DEFAULT_ZOOM = 15;
20
+ export declare const MAP_DEFAULT_TITLE = "Kart";
21
+ /** Web mercator stops here; a map centred further north cannot be drawn. */
22
+ export declare const MERCATOR_MAX_LAT = 85.0511287798066;
23
+ /**
24
+ * The frame the zoom is chosen for.
25
+ *
26
+ * The embed is told a bounding box, not a zoom, and fits that box into
27
+ * whatever size the iframe has. A box sized for a small frame keeps the zoom
28
+ * the owner chose on anything larger — a phone-width map or a wide `h-72` band
29
+ * both open at it — where a box sized for a desktop would zoom a phone out.
30
+ */
31
+ export declare const MAP_ASSUMED_WIDTH = 320;
32
+ export declare const MAP_ASSUMED_HEIGHT = 240;
33
+ /**
34
+ * A coordinate as the locale spells it.
35
+ *
36
+ * Strict about shape, because `Number('')` is 0 and a map of the Gulf of
37
+ * Guinea is the wrong way to say a value is missing. A decimal comma is
38
+ * accepted: the owner is Norwegian and so is their keyboard.
39
+ */
40
+ export declare function parseCoordinate(value: unknown): number | null;
41
+ /**
42
+ * The zoom, forgiving where the coordinates are not.
43
+ *
44
+ * A missing or unreadable zoom still gives a usable map, so it falls back to
45
+ * the default; a readable one out of range is clamped and rounded rather than
46
+ * thrown away.
47
+ */
48
+ export declare function parseZoom(value: unknown): number;
49
+ /** A location, or null when the coordinates are missing, unreadable or off the map. */
50
+ export declare function parseMapLocation(raw: RawMapContent | null | undefined): MapLocation | null;
51
+ /** Six decimals is about ten centimetres; more is noise in a content file. */
52
+ export declare function formatCoordinate(n: number): string;
53
+ /** The strings a location is stored as. */
54
+ export declare function serialiseMapLocation(location: MapLocation): {
55
+ lat: string;
56
+ lng: string;
57
+ zoom: string;
58
+ };
59
+ export interface MapBbox {
60
+ minLng: number;
61
+ minLat: number;
62
+ maxLng: number;
63
+ maxLat: number;
64
+ }
65
+ /**
66
+ * The box a frame of `width` × `height` pixels would show, centred on the
67
+ * location at its zoom. Longitude is linear in web mercator; latitude is not,
68
+ * so it goes through world pixels and back.
69
+ */
70
+ export declare function mapBbox(location: MapLocation, width?: number, height?: number): MapBbox;
71
+ /** The OpenStreetMap embed for a location, marker on the point itself. */
72
+ export declare function mapEmbedUrl(location: MapLocation): string;
73
+ /**
74
+ * Write a location to every language at once.
75
+ *
76
+ * A place is not translated, so the same numbers go into each locale, the way
77
+ * `SettoImage` writes an image path. The title is left alone: that one is
78
+ * words, and belongs to each language.
79
+ */
80
+ export declare function writeMapLocation(store: Pick<I18nStore, 'set' | 'batch'>, languages: readonly string[], key: string, location: MapLocation): void;
@@ -0,0 +1,24 @@
1
+ import type { ComponentType } from 'react';
2
+ /**
3
+ * The contract between a map on the customer's page and the tools Setto
4
+ * serves for moving it.
5
+ *
6
+ * Same split as the form field tools: the search, the draggable preview and
7
+ * the buttons live in setto-server's chrome and change with a deploy, while
8
+ * `SettoMap` — compiled into the customer's bundle — applies what they hand
9
+ * back. So this is the pinned part, and it is deliberately plain data.
10
+ */
11
+ /** A point on the map and how close to it the map opens. WGS84 degrees; zoom 1–19. */
12
+ export interface MapLocation {
13
+ lat: number;
14
+ lng: number;
15
+ zoom: number;
16
+ }
17
+ export interface MapToolsProps {
18
+ /** Where the map points now, or null when the content has no usable location. */
19
+ location: MapLocation | null;
20
+ /** Move the map. `SettoMap` writes it to every language as a draft. */
21
+ onChange: (next: MapLocation) => void;
22
+ }
23
+ /** What the chrome registers under `mountSlot('map-tools', …)`. */
24
+ export type MapToolsComponent = ComponentType<MapToolsProps>;
@@ -7,6 +7,8 @@ type Message = {
7
7
  id?: string;
8
8
  role: 'visitor' | 'assistant' | 'human' | 'system';
9
9
  content: string;
10
+ /** Server insert time; absent on local optimistic and streaming turns. */
11
+ created_at?: string;
10
12
  reveal?: boolean;
11
13
  avatarUrl?: string | null;
12
14
  authorName?: string | null;
@@ -23,6 +25,19 @@ export declare function isAtChatBottom(scrollHeight: number, scrollTop: number,
23
25
  export declare function AgentJoinedEvent({ message }: {
24
26
  message: Message;
25
27
  }): import("react/jsx-runtime").JSX.Element;
28
+ /**
29
+ * A teammate is composing. The row opts out of the transcript's polite live
30
+ * region so start/stop bursts are not announced, while its label stays
31
+ * readable when a screen-reader user browses the log.
32
+ */
33
+ export declare function TeamTypingRow({ label, avatarUrl }: {
34
+ label: string;
35
+ avatarUrl: string | null;
36
+ }): import("react/jsx-runtime").JSX.Element;
37
+ /** "Sett"/"Seen" under the visitor's latest message; not announced live. */
38
+ export declare function SeenMarker({ label }: {
39
+ label: string;
40
+ }): import("react/jsx-runtime").JSX.Element;
26
41
  export declare function SiteChatMarkdown({ content, reveal }: {
27
42
  content: string;
28
43
  reveal?: boolean;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pure decisions behind the visitor widget's Realtime doorbell, typing
3
+ * indicators and read markers. Realtime carries no chat data: every event only
4
+ * prompts the widget to refetch through the visitor-bound HTTP API, so these
5
+ * helpers never see message text, names or ids in a payload.
6
+ */
7
+ /** Fallback poll while no Realtime channel is subscribed (today's cadence). */
8
+ export declare const POLL_FALLBACK_MS = 5000;
9
+ /** Slow safety poll while the conversation channel is subscribed. */
10
+ export declare const POLL_SUBSCRIBED_MS = 30000;
11
+ /** Bursts of `changed` events collapse into one refetch. */
12
+ export declare const CHANGED_DEBOUNCE_MS = 250;
13
+ /** A visitor sends `typing: true` at most this often while composing. */
14
+ export declare const TYPING_THROTTLE_MS = 2500;
15
+ /** A team typing row disappears this long after the last `typing: true`. */
16
+ export declare const TYPING_EXPIRY_MS = 6000;
17
+ export type ChatSide = 'team' | 'visitor';
18
+ export type TypingSignal = {
19
+ side: ChatSide;
20
+ typing: boolean;
21
+ };
22
+ export type ReadSignal = {
23
+ side: ChatSide;
24
+ at: string;
25
+ };
26
+ /** The subset of a transcript message these decisions read. */
27
+ export type TimelineMessage = {
28
+ id?: string;
29
+ role: string;
30
+ kind?: string;
31
+ created_at?: string;
32
+ authorName?: string | null;
33
+ avatarUrl?: string | null;
34
+ };
35
+ export declare function pollIntervalMs(subscribed: boolean): number;
36
+ /** Only a server-issued conversation topic is joined; clients never build one. */
37
+ export declare function conversationTopic(value: unknown): string | null;
38
+ export declare function parseTypingSignal(payload: unknown): TypingSignal | null;
39
+ export declare function parseReadSignal(payload: unknown): ReadSignal | null;
40
+ /**
41
+ * Epoch milliseconds with sub-millisecond precision. Postgres returns
42
+ * microseconds (`…:05.123456+00:00`), which engines may truncate or reject, so
43
+ * the fraction is added separately and a missing zone is read as UTC.
44
+ */
45
+ export declare function timestampValue(value: string | null | undefined): number | null;
46
+ /** Monotonic merge: keep whichever valid timestamp is later. */
47
+ export declare function laterTimestamp(current: string | null, next: string | null | undefined): string | null;
48
+ export type TypingSender = {
49
+ /** Call on every draft change. */
50
+ update(draft: string): void;
51
+ /** Draft emptied, message sent, composer blurred or channel leaving. */
52
+ stop(): void;
53
+ /** Forget local state without sending (the channel is gone). */
54
+ reset(): void;
55
+ };
56
+ /**
57
+ * Throttles visitor typing signals. `send` returns false when the channel is
58
+ * not subscribed, in which case nothing is considered sent.
59
+ */
60
+ export declare function createTypingSender(send: (typing: boolean) => boolean, now?: () => number): TypingSender;
61
+ /** The widget shows only team typing; returns the new expiry deadline. */
62
+ export declare function nextTeamTypingDeadline(current: number | null, signal: TypingSignal, now: number): number | null;
63
+ /** Identity of the newest human reply, used to hide typing once it lands. */
64
+ export declare function latestHumanMessageKey(messages: readonly TimelineMessage[]): string | null;
65
+ /** First name and photo of the latest teammate who replied or joined. */
66
+ export declare function teamTypingIdentity(messages: readonly TimelineMessage[]): {
67
+ name: string | null;
68
+ avatarUrl: string | null;
69
+ };
70
+ export declare function teamTypingLabel(norwegian: boolean, name: string | null): string;
71
+ /**
72
+ * Index of the visitor message that carries "Sett"/"Seen", or -1. Only the
73
+ * latest visitor message qualifies, and only once it has a server timestamp
74
+ * that a teammate's read receipt has reached.
75
+ */
76
+ export declare function seenVisitorMessageIndex(messages: readonly TimelineMessage[], humanJoined: boolean, teamReadAt: string | null): number;
77
+ export type ReadMarkerContext = {
78
+ humanJoined: boolean;
79
+ chatOpen: boolean;
80
+ pageVisible: boolean;
81
+ /** The latest `through` already sent or in flight for this conversation. */
82
+ lastSent: string | null;
83
+ };
84
+ /**
85
+ * The `through` value to POST as the visitor's read marker, or null when no
86
+ * marker should be sent: no human has joined, the chat is closed or hidden, or
87
+ * nothing newer than the last marker has arrived from Barista or the team.
88
+ */
89
+ export declare function readMarkerThrough(messages: readonly TimelineMessage[], context: ReadMarkerContext): string | null;
90
+ export type RefreshTicket = {
91
+ conversationId: string | null;
92
+ /** Increments whenever a visitor turn starts. */
93
+ turn: number;
94
+ /** Increments whenever a newer refetch or conversation load starts. */
95
+ request: number;
96
+ };
97
+ /**
98
+ * What to do with a conversation refetch when it resolves. A response that
99
+ * lands during (or overlapped) a visitor turn would replace the locally
100
+ * streaming answer, so it is deferred and re-requested once the turn ends.
101
+ */
102
+ export declare function refreshOutcome(started: RefreshTicket, current: RefreshTicket & {
103
+ busy: boolean;
104
+ }): 'apply' | 'defer' | 'drop';