@mediansh/widget 0.5.0 → 0.7.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/README.md CHANGED
@@ -5,6 +5,9 @@ support panel. An AI agent answers first, your team picks up in the Median
5
5
  inbox, and replies stream back to the visitor. There is no connection or
6
6
  message list to manage.
7
7
 
8
+ The same panel as a modal, a contact form, and a feedback panel ship
9
+ alongside it. Same key, same inbox.
10
+
8
11
  ## Install
9
12
 
10
13
  ```sh
@@ -66,6 +69,51 @@ medianSupport.reset(); // on sign out
66
69
  React components can use `useMedianSupport()` for the same control with a
67
70
  live `isOpen`.
68
71
 
72
+ ## Modal
73
+
74
+ The widget, opened in the middle of the screen over a scrim rather than out
75
+ of the corner. The button stays; every prop and every control is the same.
76
+
77
+ ```tsx
78
+ import { MedianSupportModal } from "@mediansh/widget";
79
+
80
+ <MedianSupportModal apiKey="median_pk_..." />;
81
+ ```
82
+
83
+ `mountMedianSupportModal` is the version for pages without React. Mount one
84
+ of the two, not both.
85
+
86
+ ## Contact form
87
+
88
+ A form where you put it, for the page where a chat panel is the wrong shape.
89
+ An email and a message are always asked; add the rest:
90
+
91
+ ```tsx
92
+ import { MedianContactForm } from "@mediansh/widget";
93
+
94
+ <MedianContactForm
95
+ apiKey="median_pk_..."
96
+ fields={[
97
+ { name: "name", label: "Name", required: true },
98
+ { name: "topic", type: "select", options: ["Billing", "A bug"] },
99
+ ]}
100
+ />;
101
+ ```
102
+
103
+ Sending starts a conversation in your inbox, exactly as the widget does, and
104
+ the reply goes to the email given. `mountMedianContactForm({ target, ... })`
105
+ renders it into an element of yours on a page without React.
106
+
107
+ Or keep your own form and only borrow the send:
108
+
109
+ ```tsx
110
+ const contact = useMedianContact({ apiKey: "median_pk_..." });
111
+
112
+ await contact.send({ email, message, fields: { Topic: topic } });
113
+ ```
114
+
115
+ `sendMedianContact(options, message)` is the same call as a plain function.
116
+
69
117
  ## Feedback
70
118
 
71
119
  A second panel, for notes rather than questions. It wraps a trigger of your
@@ -88,11 +136,12 @@ and the page it was written on rides along.
88
136
 
89
137
  ## Theming
90
138
 
91
- Both panels read shadcn-style CSS variables from your page and fall back to
92
- a plain light theme without them. They inherit your font.
139
+ Every piece reads shadcn-style CSS variables from your page and falls back
140
+ to a plain light theme without them. They inherit your font.
93
141
 
94
142
  ## Docs
95
143
 
96
- Props, identity, diagnostics, feedback, and agent tools:
144
+ Props, identity, diagnostics, the modal, the contact form, feedback, and
145
+ agent tools:
97
146
  [docs.median.sh](https://docs.median.sh), or the source at
98
147
  [github.com/mediansh/median](https://github.com/mediansh/median).
package/dist/index.d.ts CHANGED
@@ -177,16 +177,20 @@ type MedianSupportOptions = {
177
177
  /** Every open and close, whoever asked for it. */
178
178
  onOpenChange?: (open: boolean) => void;
179
179
  /**
180
- * Take the visitor to a page, when one of your own tools asks for it.
180
+ * Take the visitor to a page, when one of your own tools offers one.
181
181
  *
182
182
  * <MedianSupport apiKey="median_pk_..." onNavigate={router.push} />
183
183
  *
184
+ * A tool that answers with a destination puts a button under the reply
185
+ * that explains it, and this is what the button does. Nothing moves on its
186
+ * own: the visitor presses it, or they stay where they are.
187
+ *
184
188
  * Called with a path on your own site, already checked against this page's
185
189
  * origin. Your router decides what happens: pass `router.push` and the
186
190
  * conversation stays open across the move.
187
191
  *
188
- * Without it a tool that asks to navigate cannot, and says so in the
189
- * console while you are building. Nothing else needs it.
192
+ * Without it there is nowhere for a press to go, so no button is shown and
193
+ * the console says so while you are building. Nothing else needs it.
190
194
  */
191
195
  onNavigate?: (path: string) => void;
192
196
  };
@@ -256,19 +260,26 @@ type MedianSupportProps = MedianSupportOptions & ({
256
260
  declare function MedianSupport(props: MedianSupportProps): react.JSX.Element;
257
261
 
258
262
  /**
259
- * What a mounted widget hands back: the two things a host without React has
260
- * no re-render to do for them. Everything else goes through `medianSupport`,
261
- * same as anywhere.
263
+ * What a mounted component hands back: the two things a host without React
264
+ * has no re-render to do for them. Everything else goes through the
265
+ * component's own control, same as anywhere.
262
266
  */
263
- type MedianSupportInstance = {
267
+ type MedianMountInstance<Options> = {
264
268
  /**
265
269
  * Change any option after mount. Only the keys passed change, so
266
270
  * `update({ user })` when somebody signs in leaves the rest alone.
267
271
  */
268
- update: (options: Partial<MedianSupportProps>) => void;
269
- /** Take the widget off the page: panel, launcher, connection, all of it. */
272
+ update: (options: Partial<Options>) => void;
273
+ /** Take it off the page: the component, its container, its connection. */
270
274
  unmount: () => void;
271
275
  };
276
+
277
+ /**
278
+ * What a mounted widget hands back: the two things a host without React has
279
+ * no re-render to do for them. Everything else goes through `medianSupport`,
280
+ * same as anywhere.
281
+ */
282
+ type MedianSupportInstance = MedianMountInstance<MedianSupportProps>;
272
283
  /**
273
284
  * The same widget, for a page that has never heard of React: Vue, Svelte,
274
285
  * Rails, a script on plain HTML. One call puts the launcher in the corner,
@@ -289,6 +300,43 @@ type MedianSupportInstance = {
289
300
  */
290
301
  declare function mountMedianSupport(options: MedianSupportProps): MedianSupportInstance;
291
302
 
303
+ /**
304
+ * Every prop the widget takes, unchanged. The modal is the widget in a
305
+ * different place, not a different widget.
306
+ */
307
+ type MedianSupportModalProps = MedianSupportProps;
308
+ /**
309
+ * The support widget as a modal: the same button in the corner, and a panel
310
+ * that opens in the middle of the screen over a scrim rather than out of
311
+ * the corner.
312
+ *
313
+ * <MedianSupportModal apiKey="median_pk_..." />
314
+ *
315
+ * Everything else is the widget's. Same props, same thread, same inbox on
316
+ * the other side, same `useMedianSupport()` to open it from your own nav,
317
+ * same sheet on a phone. Pick this for a site where a conversation deserves
318
+ * the room, or where a panel floating over the page would be in the way of
319
+ * the page.
320
+ *
321
+ * One or the other. Mounting both shares one open state, and the console
322
+ * says so.
323
+ */
324
+ declare function MedianSupportModal(props: MedianSupportModalProps): react.JSX.Element;
325
+
326
+ type MedianSupportModalInstance = MedianMountInstance<MedianSupportModalProps>;
327
+ /**
328
+ * The modal, for a page that has never heard of React: Vue, Svelte, Rails,
329
+ * a script on plain HTML.
330
+ *
331
+ * const support = mountMedianSupportModal({ apiKey: "median_pk_..." });
332
+ *
333
+ * It takes the component's props as one options object, and the rest of the
334
+ * SDK does not know the difference: `medianSupport.open()` opens this modal
335
+ * exactly as it opens the component. `update` and `unmount` are the same two
336
+ * `mountMedianSupport` hands back.
337
+ */
338
+ declare function mountMedianSupportModal(options: MedianSupportModalProps): MedianSupportModalInstance;
339
+
292
340
  /**
293
341
  * Filing the crash the visitor is looking at.
294
342
  *
@@ -533,15 +581,7 @@ declare function MedianFeedback(props: MedianFeedbackProps): react.JSX.Element;
533
581
  * no re-render to do for them. Opening and closing go through
534
582
  * `medianFeedback`, same as anywhere.
535
583
  */
536
- type MedianFeedbackInstance = {
537
- /**
538
- * Change any option after mount. Only the keys passed change, so
539
- * `update({ user })` when somebody signs in leaves the rest alone.
540
- */
541
- update: (options: Partial<MedianFeedbackProps>) => void;
542
- /** Take the panel off the page. */
543
- unmount: () => void;
544
- };
584
+ type MedianFeedbackInstance = MedianMountInstance<MedianFeedbackProps>;
545
585
  /**
546
586
  * The same panel, for a page that has never heard of React: Vue, Svelte,
547
587
  * Rails, a script on plain HTML.
@@ -583,4 +623,299 @@ type MedianFeedbackControl = {
583
623
  */
584
624
  declare function useMedianFeedback(): MedianFeedbackControl;
585
625
 
586
- export { type MedianDiagnostics, type MedianErrorReportOptions, type MedianErrorReportOutcome, MedianFeedback, type MedianFeedbackControl, type MedianFeedbackInstance, type MedianFeedbackProps, MedianSupport, type MedianSupportControl, type MedianSupportInstance, type MedianSupportProps, type MedianUser, type MedianDiagnostics as PortalDiagnostics, type MedianErrorReportOptions as PortalErrorReportOptions, type MedianErrorReportOutcome as PortalErrorReportOutcome, MedianFeedback as PortalFeedback, type MedianFeedbackControl as PortalFeedbackControl, type MedianFeedbackInstance as PortalFeedbackInstance, type MedianFeedbackProps as PortalFeedbackProps, MedianSupport as PortalSupport, type MedianSupportControl as PortalSupportControl, type MedianSupportInstance as PortalSupportInstance, type MedianSupportProps as PortalSupportProps, type MedianUser as PortalUser, type SupportLauncherKind, medianFeedback, medianSupport, mountMedianFeedback, mountMedianSupport, mountMedianFeedback as mountPortalFeedback, mountMedianSupport as mountPortalSupport, medianFeedback as portalFeedback, medianSupport as portalSupport, reportError, useCanReportError, useMedianFeedback, useMedianSupport, useMedianFeedback as usePortalFeedback, useMedianSupport as usePortalSupport };
626
+ /**
627
+ * What a host can put on the contact form, and what the form always has.
628
+ *
629
+ * Two fields are on every form whatever the host says: an email, because a
630
+ * message nobody can reply to is a message nobody sent, and the message
631
+ * itself. Everything else is the host's to add: a name, a topic, an order
632
+ * number, whatever their team wants to know before they read.
633
+ */
634
+ /** A line, a paragraph, or one of a list. */
635
+ type MedianContactFieldKind = "text" | "textarea" | "select";
636
+ /**
637
+ * One field a host adds to the form.
638
+ *
639
+ * { name: "topic", label: "Topic", type: "select", options: ["Billing", "A bug"] }
640
+ *
641
+ * Three names are reserved. `email` and `message` are the two the form always
642
+ * has, and naming either here only changes its label and placeholder. `name`
643
+ * lands on the customer record beside the email rather than in the message,
644
+ * so the team sees who wrote rather than a line saying so.
645
+ */
646
+ type MedianContactField = {
647
+ /** Its key. Rides the message as the label on its line. */
648
+ name: string;
649
+ /** Shown above the box. Made from `name` when left out. */
650
+ label?: string;
651
+ /** `text` by default. Ignored on `email` and `message`, which know their shape. */
652
+ type?: MedianContactFieldKind;
653
+ /** What a `select` offers. A select with none falls back to a text box. */
654
+ options?: string[];
655
+ placeholder?: string;
656
+ /** Off by default. `email` and `message` are required whatever this says. */
657
+ required?: boolean;
658
+ };
659
+
660
+ type MedianContactFormOptions = {
661
+ /**
662
+ * What the form asks, beyond an email and a message, which it always asks:
663
+ *
664
+ * <MedianContactForm
665
+ * apiKey="median_pk_..."
666
+ * fields={[
667
+ * { name: "name", label: "Name", required: true },
668
+ * { name: "topic", type: "select", options: ["Billing", "A bug", "Something else"] },
669
+ * ]}
670
+ * />
671
+ *
672
+ * Shown in this order. Email goes first, or right after a `name` field,
673
+ * and the message goes last, unless either is placed here by name. A
674
+ * `name` field lands on the customer record; every other answer goes
675
+ * above the message as a line of its own.
676
+ */
677
+ fields?: MedianContactField[];
678
+ /**
679
+ * Who is signed in on your site, if anyone. Their email and name fill the
680
+ * boxes in, and the rest is shown to your team.
681
+ */
682
+ user?: MedianUser;
683
+ /**
684
+ * Whether the message carries what the browser already knows: time zone,
685
+ * language, browser, OS, device, screen, and the page it was sent from.
686
+ * On by default. Pass `false` and none of it is read at all.
687
+ */
688
+ telemetry?: boolean;
689
+ /**
690
+ * Watch what goes wrong on the page, so a bug report filed from the
691
+ * message arrives with the error already attached. The same switch
692
+ * `<MedianSupport>` takes, and the same buffer behind it.
693
+ */
694
+ diagnostics?: MedianDiagnostics;
695
+ /**
696
+ * Anything about your own app worth having next to a bug report: a build,
697
+ * a route, a flag. Read when the form is sent.
698
+ */
699
+ appState?: Record<string, string | number | boolean>;
700
+ /** The heading, for a page that calls this something else. */
701
+ title?: string;
702
+ /** The line under it. */
703
+ description?: string;
704
+ /**
705
+ * The message landed. For whatever your page does next: a thank-you of its
706
+ * own, a redirect, an event. The form has already said thank you itself.
707
+ */
708
+ onSent?: () => void;
709
+ };
710
+ /**
711
+ * Two ways in, the same two `<MedianSupport>` takes.
712
+ *
713
+ * `apiKey` is the publishable id on its own. `identity` is the path to a
714
+ * route built with `medianIdentity`, which answers with the key and with
715
+ * whoever is signed in, already signed. Pass both and the form is up on the
716
+ * key from the prop while the route is still answering.
717
+ */
718
+ type MedianContactFormProps = MedianContactFormOptions & ({
719
+ apiKey: string;
720
+ identity?: string;
721
+ } | {
722
+ apiKey?: string;
723
+ identity: string;
724
+ });
725
+ /**
726
+ * A contact form, for the page where a chat panel is the wrong shape.
727
+ *
728
+ * <MedianContactForm apiKey="median_pk_..." />
729
+ *
730
+ * An email and a message, on a card the shape of the support panel, with
731
+ * whatever else your team wants asked first. What is sent starts a
732
+ * conversation in your inbox exactly as the widget would: the agent answers
733
+ * first, your team picks it up, and the reply goes to the email given.
734
+ *
735
+ * Built on `useMedianContact`, which is also what a form of your own uses.
736
+ * It needs no connection of its own, and it is independent of
737
+ * `<MedianSupport>`: mount either, both, or neither. Mounted together they
738
+ * share a visitor, so somebody who writes here and later opens the widget
739
+ * finds the same thread.
740
+ */
741
+ declare function MedianContactForm(props: MedianContactFormProps): react.JSX.Element;
742
+
743
+ /**
744
+ * The component's props, plus where on the page the form goes.
745
+ *
746
+ * Spelled out rather than `Omit`ped off the props, because omitting a key
747
+ * from a union of two shapes flattens it into one and loses the "either,
748
+ * but not neither" the union is there to say.
749
+ */
750
+ type MedianContactFormMountOptions = MedianContactFormProps & {
751
+ /**
752
+ * The element the form is rendered into, or a selector for it:
753
+ *
754
+ * mountMedianContactForm({ target: "#contact", apiKey: "median_pk_..." });
755
+ *
756
+ * Unlike the widget, a form has no corner to go to, so this is required.
757
+ */
758
+ target: string | Element;
759
+ };
760
+ type MedianContactFormInstance = MedianMountInstance<MedianContactFormProps>;
761
+ /**
762
+ * The same form, for a page that has never heard of React: Vue, Svelte,
763
+ * Rails, a script on plain HTML.
764
+ *
765
+ * const form = mountMedianContactForm({
766
+ * target: "#contact",
767
+ * apiKey: "median_pk_...",
768
+ * fields: [{ name: "name", label: "Name" }],
769
+ * });
770
+ *
771
+ * It takes the component's props as one options object, plus `target`, and
772
+ * renders into a container of its own inside that element with the
773
+ * package's copy of React. Whatever else the element holds is left alone.
774
+ */
775
+ declare function mountMedianContactForm({ target, ...options }: MedianContactFormMountOptions): MedianContactFormInstance;
776
+
777
+ /**
778
+ * What a contact form hands over, ours or a host's own.
779
+ *
780
+ * The shape is deliberately small: the two things a message needs to be
781
+ * answered, who wrote it if they said, and whatever else the form asked as
782
+ * label to answer. Building one from a `<form>` of your own is a few lines.
783
+ */
784
+ type MedianContactMessage = {
785
+ /** Where the reply goes. */
786
+ email: string;
787
+ /** What they wrote. */
788
+ message: string;
789
+ /** Lands on the customer record beside the email. */
790
+ name?: string;
791
+ /**
792
+ * Everything else the form asked, as label to answer. Each goes above the
793
+ * message as a line of its own, so the team reads the form the way it was
794
+ * filled in:
795
+ *
796
+ * { Topic: "Billing", "Order number": "4821" }
797
+ *
798
+ * Empty answers are left off.
799
+ */
800
+ fields?: Record<string, string>;
801
+ };
802
+
803
+ /**
804
+ * Sending a contact message, for a form of the host's own.
805
+ *
806
+ * The same door the widget's composer uses, on the same session token, so
807
+ * what a host's form sends starts the thread the corner would have started:
808
+ * the team answers it from the inbox, the agent goes first, and with no
809
+ * panel open to watch, the reply goes to the email given. Somebody who
810
+ * writes here and later opens the widget finds their own conversation.
811
+ *
812
+ * A stateless HTTP client rather than the subscribing one, because nothing
813
+ * is read back: a form is a thing you hand over, not a thread you watch.
814
+ */
815
+ /** What a send is told about who is sending and from where. */
816
+ type MedianContactOptions = {
817
+ /** The browser-safe median_pk_ id. */
818
+ apiKey: string;
819
+ /**
820
+ * Who is signed in on your site, if anyone. What the form says about the
821
+ * email and the name wins over this; the rest is shown to your team.
822
+ */
823
+ user?: MedianUser;
824
+ /**
825
+ * Whether the message carries what the browser already knows: time zone,
826
+ * language, browser, OS, device, screen, and the page it was sent from.
827
+ * On by default. Pass `false` and none of it is read at all.
828
+ */
829
+ telemetry?: boolean;
830
+ };
831
+ /**
832
+ * Send a contact message from a form of your own, on any page in the
833
+ * browser:
834
+ *
835
+ * await sendMedianContact(
836
+ * { apiKey: "median_pk_..." },
837
+ * { email, message, name, fields: { Topic: topic } },
838
+ * );
839
+ *
840
+ * It resolves once the message is in your inbox and rejects with an `Error`
841
+ * whose `message` is worth showing above your button. Nothing about the
842
+ * form is ours: any `<form>`, any framework, any styling.
843
+ *
844
+ * In React, `useMedianContact()` is the same call with `isSending` and
845
+ * `error` kept for you, and an identity route resolved.
846
+ */
847
+ declare function sendMedianContact(options: MedianContactOptions, message: MedianContactMessage): Promise<void>;
848
+
849
+ type MedianContactHookOptions = {
850
+ /** Who is signed in on your site, if anyone. */
851
+ user?: MedianUser;
852
+ /** Browser context on the message. On by default. */
853
+ telemetry?: boolean;
854
+ /**
855
+ * Watch what goes wrong on the page, so a bug report filed from the
856
+ * message arrives with the error already attached. The same switch
857
+ * `<MedianSupport>` takes, and the same buffer behind it.
858
+ */
859
+ diagnostics?: MedianDiagnostics;
860
+ /** Anything about your own app worth having next to a bug report. */
861
+ appState?: Record<string, string | number | boolean>;
862
+ };
863
+ /**
864
+ * Two ways in, the same two every component takes: the publishable key, or
865
+ * the path of a route built with `medianIdentity`, which answers with the
866
+ * key and whoever is signed in.
867
+ */
868
+ type UseMedianContactOptions = MedianContactHookOptions & ({
869
+ apiKey: string;
870
+ identity?: string;
871
+ } | {
872
+ apiKey?: string;
873
+ identity: string;
874
+ });
875
+ /** What the hook hands back: one call, and the state around it. */
876
+ type MedianContactControl = {
877
+ /**
878
+ * Send what the form holds. Resolves once the message is in the inbox.
879
+ * Rejects with an `Error` whose `message` is worth showing, and `error`
880
+ * holds the same words until the next send.
881
+ */
882
+ send: (message: MedianContactMessage) => Promise<void>;
883
+ /** A send is in flight. Hold the button. */
884
+ isSending: boolean;
885
+ /** Why the last send failed, or null. Cleared by the next send. */
886
+ error: string | null;
887
+ /**
888
+ * Whether a send has somewhere to go. False on the server, and for the
889
+ * beat before an identity route has answered with the key.
890
+ */
891
+ isReady: boolean;
892
+ /**
893
+ * Who is typing, as far as the page knows: what you passed, what the
894
+ * identity route said, and the email the widget's own question was
895
+ * answered with on this browser. For filling the boxes in.
896
+ */
897
+ user: MedianUser | undefined;
898
+ };
899
+ /**
900
+ * A contact form of your own, on Median's inbox:
901
+ *
902
+ * const contact = useMedianContact({ apiKey: "median_pk_..." });
903
+ *
904
+ * <form
905
+ * onSubmit={async (event) => {
906
+ * event.preventDefault();
907
+ * await contact.send({ email, message });
908
+ * }}
909
+ * >
910
+ * ...
911
+ * {contact.error && <p>{contact.error}</p>}
912
+ * <button disabled={contact.isSending}>Send</button>
913
+ * </form>
914
+ *
915
+ * Everything about the form is yours. What is sent starts a conversation in
916
+ * your inbox exactly as `<MedianContactForm>` and the widget do, and the
917
+ * reply goes to the email given. No provider, no connection to manage.
918
+ */
919
+ declare function useMedianContact(options: UseMedianContactOptions): MedianContactControl;
920
+
921
+ export { type MedianContactControl, type MedianContactField, type MedianContactFieldKind, MedianContactForm, type MedianContactFormInstance, type MedianContactFormMountOptions, type MedianContactFormProps, type MedianContactMessage, type MedianContactOptions, type MedianDiagnostics, type MedianErrorReportOptions, type MedianErrorReportOutcome, MedianFeedback, type MedianFeedbackControl, type MedianFeedbackInstance, type MedianFeedbackProps, MedianSupport, type MedianSupportControl, type MedianSupportInstance, MedianSupportModal, type MedianSupportModalInstance, type MedianSupportModalProps, type MedianSupportProps, type MedianUser, type MedianDiagnostics as PortalDiagnostics, type MedianErrorReportOptions as PortalErrorReportOptions, type MedianErrorReportOutcome as PortalErrorReportOutcome, MedianFeedback as PortalFeedback, type MedianFeedbackControl as PortalFeedbackControl, type MedianFeedbackInstance as PortalFeedbackInstance, type MedianFeedbackProps as PortalFeedbackProps, MedianSupport as PortalSupport, type MedianSupportControl as PortalSupportControl, type MedianSupportInstance as PortalSupportInstance, type MedianSupportProps as PortalSupportProps, type MedianUser as PortalUser, type SupportLauncherKind, type UseMedianContactOptions, medianFeedback, medianSupport, mountMedianContactForm, mountMedianFeedback, mountMedianSupport, mountMedianSupportModal, mountMedianFeedback as mountPortalFeedback, mountMedianSupport as mountPortalSupport, medianFeedback as portalFeedback, medianSupport as portalSupport, reportError, sendMedianContact, useCanReportError, useMedianContact, useMedianFeedback, useMedianSupport, useMedianFeedback as usePortalFeedback, useMedianSupport as usePortalSupport };