@uranotools/urano-social 0.0.0-stage → 0.3.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.
Files changed (41) hide show
  1. package/.env.example +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +212 -2
  4. package/dist/client/createUranoChat.d.ts +16 -0
  5. package/dist/client/createUranoChat.js +223 -0
  6. package/dist/client/index.d.ts +6 -0
  7. package/dist/client/index.js +4 -0
  8. package/dist/client/recaptcha.d.ts +14 -0
  9. package/dist/client/recaptcha.js +36 -0
  10. package/dist/client/sse.d.ts +3 -0
  11. package/dist/client/sse.js +104 -0
  12. package/dist/client/submitContactForm.d.ts +61 -0
  13. package/dist/client/submitContactForm.js +133 -0
  14. package/dist/crypto.d.ts +5 -0
  15. package/dist/crypto.js +35 -0
  16. package/dist/flavors/UranoSocialChatElement.d.ts +13 -0
  17. package/dist/flavors/UranoSocialChatElement.js +173 -0
  18. package/dist/flavors/UranoSocialContactFormElement.d.ts +9 -0
  19. package/dist/flavors/UranoSocialContactFormElement.js +103 -0
  20. package/dist/flavors/styles.css +257 -0
  21. package/dist/index.d.ts +14 -0
  22. package/dist/index.js +10 -0
  23. package/dist/react/UranoSocialChat.d.ts +38 -0
  24. package/dist/react/UranoSocialChat.js +81 -0
  25. package/dist/react/UranoSocialContactForm.d.ts +19 -0
  26. package/dist/react/UranoSocialContactForm.js +78 -0
  27. package/dist/react/index.d.ts +4 -0
  28. package/dist/react/index.js +2 -0
  29. package/dist/server/antiSpam.d.ts +76 -0
  30. package/dist/server/antiSpam.js +204 -0
  31. package/dist/server/index.d.ts +4 -0
  32. package/dist/server/index.js +4 -0
  33. package/dist/server/proxy.d.ts +16 -0
  34. package/dist/server/proxy.js +253 -0
  35. package/dist/server/visitorCookie.d.ts +11 -0
  36. package/dist/server/visitorCookie.js +38 -0
  37. package/dist/server/websocket.d.ts +21 -0
  38. package/dist/server/websocket.js +126 -0
  39. package/dist/types.d.ts +94 -0
  40. package/dist/types.js +3 -0
  41. package/package.json +92 -4
@@ -0,0 +1,61 @@
1
+ export interface ContactFormPayload {
2
+ name?: string;
3
+ email: string;
4
+ message: string;
5
+ subject?: string;
6
+ phone?: string;
7
+ company?: string;
8
+ /**
9
+ * Honeypot value — must stay empty. Bots that fill it are silently dropped
10
+ * by the proxy (fake success, no AI call).
11
+ */
12
+ website?: string;
13
+ /** Signed ticket from GET ?action=contact-ticket (auto-fetched when omitted). */
14
+ contactTicket?: string;
15
+ /** reCAPTCHA v3 token (or pass `recaptchaSiteKey` in options to auto-execute). */
16
+ captchaToken?: string;
17
+ }
18
+ export interface ContactFormResult {
19
+ ok: boolean;
20
+ text?: string;
21
+ sessionId?: string;
22
+ turnId?: string;
23
+ delivered?: string;
24
+ error?: string;
25
+ status: number;
26
+ raw?: unknown;
27
+ }
28
+ export interface ContactTicketResponse {
29
+ ticket: string;
30
+ minAgeMs: number;
31
+ maxAgeMs: number;
32
+ honeypotField: string;
33
+ recaptchaRequired: boolean;
34
+ recaptchaAction: string;
35
+ }
36
+ export interface SubmitContactFormOptions {
37
+ /** Site proxy endpoint. Default `/api/urano-social`. */
38
+ endpoint?: string;
39
+ /**
40
+ * Prefer GET ?mode=wait&payload=…
41
+ * Default **false** (POST) so tickets / captcha tokens stay out of URLs and logs.
42
+ */
43
+ useGet?: boolean;
44
+ signal?: AbortSignal;
45
+ /** When set, loads grecaptcha and executes action `contact` before submit. */
46
+ recaptchaSiteKey?: string;
47
+ /** Override reCAPTCHA action (must match server). Default `contact`. */
48
+ recaptchaAction?: string;
49
+ /** Skip auto ticket fetch (tests / custom flows). */
50
+ skipTicket?: boolean;
51
+ }
52
+ /** Prefetch a signed contact ticket (call on form mount). */
53
+ export declare function fetchContactTicket(endpoint?: string, signal?: AbortSignal): Promise<ContactTicketResponse>;
54
+ /**
55
+ * Submits a contact form through the site proxy.
56
+ * Default transport is POST + mode=wait (signed upstream by the server).
57
+ *
58
+ * Prefer calling `fetchContactTicket` when the form mounts so humans don't
59
+ * wait the min-age delay at submit time.
60
+ */
61
+ export declare function submitContactForm(input: ContactFormPayload, options?: SubmitContactFormOptions): Promise<ContactFormResult>;
@@ -0,0 +1,133 @@
1
+ import { executeRecaptchaV3 } from './recaptcha';
2
+ function sleep(ms, signal) {
3
+ if (ms <= 0)
4
+ return Promise.resolve();
5
+ return new Promise((resolve, reject) => {
6
+ const timer = setTimeout(resolve, ms);
7
+ signal?.addEventListener('abort', () => {
8
+ clearTimeout(timer);
9
+ reject(new DOMException('Aborted', 'AbortError'));
10
+ }, { once: true });
11
+ });
12
+ }
13
+ /** Prefetch a signed contact ticket (call on form mount). */
14
+ export async function fetchContactTicket(endpoint = '/api/urano-social', signal) {
15
+ const base = endpoint.replace(/\/$/, '');
16
+ const res = await fetch(`${base}?action=contact-ticket`, {
17
+ method: 'GET',
18
+ credentials: 'include',
19
+ signal,
20
+ headers: { Accept: 'application/json' },
21
+ });
22
+ const raw = await res.json().catch(() => ({}));
23
+ if (!res.ok || !raw?.ticket) {
24
+ throw new Error(String(raw?.error || 'contact_ticket_failed'));
25
+ }
26
+ const minAgeMs = Number(raw.minAgeMs);
27
+ const maxAgeMs = Number(raw.maxAgeMs);
28
+ return {
29
+ ticket: String(raw.ticket),
30
+ minAgeMs: Number.isFinite(minAgeMs) ? minAgeMs : 2500,
31
+ maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : 30 * 60 * 1000,
32
+ honeypotField: String(raw.honeypotField || 'website'),
33
+ recaptchaRequired: Boolean(raw.recaptchaRequired),
34
+ recaptchaAction: String(raw.recaptchaAction || 'contact'),
35
+ };
36
+ }
37
+ /**
38
+ * Submits a contact form through the site proxy.
39
+ * Default transport is POST + mode=wait (signed upstream by the server).
40
+ *
41
+ * Prefer calling `fetchContactTicket` when the form mounts so humans don't
42
+ * wait the min-age delay at submit time.
43
+ */
44
+ export async function submitContactForm(input, options = {}) {
45
+ const endpoint = (options.endpoint || '/api/urano-social').replace(/\/$/, '');
46
+ const email = String(input.email || '').trim();
47
+ const message = String(input.message || '').trim();
48
+ if (!email || !message) {
49
+ return { ok: false, status: 400, error: 'email_and_message_required' };
50
+ }
51
+ let contactTicket = input.contactTicket;
52
+ if (!contactTicket && !options.skipTicket) {
53
+ try {
54
+ const issued = await fetchContactTicket(endpoint, options.signal);
55
+ contactTicket = issued.ticket;
56
+ // Ticket was just minted — wait min age before POST (bot / headless path).
57
+ await sleep(issued.minAgeMs, options.signal);
58
+ }
59
+ catch (error) {
60
+ return {
61
+ ok: false,
62
+ status: 403,
63
+ error: error?.message || 'contact_ticket_failed',
64
+ };
65
+ }
66
+ }
67
+ let captchaToken = input.captchaToken;
68
+ if (!captchaToken && options.recaptchaSiteKey) {
69
+ try {
70
+ captchaToken = await executeRecaptchaV3(options.recaptchaSiteKey, options.recaptchaAction || 'contact');
71
+ }
72
+ catch (error) {
73
+ return {
74
+ ok: false,
75
+ status: 403,
76
+ error: error?.message || 'recaptcha_failed',
77
+ };
78
+ }
79
+ }
80
+ const payload = {
81
+ type: 'contact',
82
+ email,
83
+ name: input.name || undefined,
84
+ subject: input.subject || undefined,
85
+ message,
86
+ phone: input.phone || undefined,
87
+ company: input.company || undefined,
88
+ website: input.website ?? '',
89
+ contactTicket,
90
+ captchaToken: captchaToken || undefined,
91
+ };
92
+ const useGet = options.useGet === true;
93
+ let response;
94
+ if (useGet) {
95
+ const url = `${endpoint}?mode=wait&payload=${encodeURIComponent(JSON.stringify(payload))}`;
96
+ response = await fetch(url, {
97
+ method: 'GET',
98
+ credentials: 'include',
99
+ signal: options.signal,
100
+ headers: { Accept: 'application/json' },
101
+ });
102
+ }
103
+ else {
104
+ response = await fetch(`${endpoint}?mode=wait`, {
105
+ method: 'POST',
106
+ credentials: 'include',
107
+ signal: options.signal,
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ Accept: 'application/json',
111
+ },
112
+ body: JSON.stringify(payload),
113
+ });
114
+ }
115
+ const raw = await response.json().catch(() => ({}));
116
+ if (!response.ok) {
117
+ return {
118
+ ok: false,
119
+ status: response.status,
120
+ error: String(raw.error || raw.message || `http_${response.status}`),
121
+ raw,
122
+ };
123
+ }
124
+ return {
125
+ ok: true,
126
+ status: response.status,
127
+ text: String(raw.text || raw.response || ''),
128
+ sessionId: raw.sessionId ? String(raw.sessionId) : undefined,
129
+ turnId: raw.turnId ? String(raw.turnId) : undefined,
130
+ delivered: raw.delivered ? String(raw.delivered) : undefined,
131
+ raw,
132
+ };
133
+ }
@@ -0,0 +1,5 @@
1
+ export declare function signHmacHex(payload: string | Buffer, secret: string): string;
2
+ export declare function verifyHmacHex(payload: string | Buffer, signature: string, secret: string): boolean;
3
+ export declare function randomId(prefix?: string): string;
4
+ export declare function signCookieValue(value: string, secret: string): string;
5
+ export declare function verifyCookieValue(signed: string, secret: string): string | null;
package/dist/crypto.js ADDED
@@ -0,0 +1,35 @@
1
+ import * as crypto from 'crypto';
2
+ export function signHmacHex(payload, secret) {
3
+ return crypto.createHmac('sha256', secret).update(payload).digest('hex');
4
+ }
5
+ export function verifyHmacHex(payload, signature, secret) {
6
+ try {
7
+ const expected = signHmacHex(payload, secret);
8
+ const clean = String(signature || '').replace(/^(sha256=|sha1=)/i, '');
9
+ const a = Buffer.from(clean, 'hex');
10
+ const b = Buffer.from(expected, 'hex');
11
+ if (a.length !== b.length || a.length === 0)
12
+ return false;
13
+ return crypto.timingSafeEqual(a, b);
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export function randomId(prefix = 'vis') {
20
+ return `${prefix}_${crypto.randomBytes(16).toString('hex')}`;
21
+ }
22
+ export function signCookieValue(value, secret) {
23
+ const sig = signHmacHex(value, secret);
24
+ return `${value}.${sig}`;
25
+ }
26
+ export function verifyCookieValue(signed, secret) {
27
+ const idx = signed.lastIndexOf('.');
28
+ if (idx <= 0)
29
+ return null;
30
+ const value = signed.slice(0, idx);
31
+ const sig = signed.slice(idx + 1);
32
+ if (!verifyHmacHex(value, sig, secret))
33
+ return null;
34
+ return value;
35
+ }
@@ -0,0 +1,13 @@
1
+ import { type UranoChat } from '../client/createUranoChat';
2
+ export type SocialFlavor = 'floating' | 'sidebar' | 'headless';
3
+ export declare class UranoSocialChatElement extends HTMLElement {
4
+ #private;
5
+ static get observedAttributes(): string[];
6
+ constructor();
7
+ connectedCallback(): void;
8
+ disconnectedCallback(): void;
9
+ attributeChangedCallback(): void;
10
+ get chat(): UranoChat | null;
11
+ render(): void;
12
+ }
13
+ export declare function registerUranoSocialChat(tagName?: string): void;
@@ -0,0 +1,173 @@
1
+ import { createUranoChat } from '../client/createUranoChat';
2
+ export class UranoSocialChatElement extends HTMLElement {
3
+ static get observedAttributes() {
4
+ return ['flavor', 'endpoint', 'title', 'open', 'displayname'];
5
+ }
6
+ #chat = null;
7
+ #shadow;
8
+ #open = false;
9
+ #messages = [];
10
+ #error = '';
11
+ #draft = '';
12
+ constructor() {
13
+ super();
14
+ this.#shadow = this.attachShadow({ mode: 'open' });
15
+ }
16
+ connectedCallback() {
17
+ this.#open = this.getAttribute('open') === 'true' || this.getAttribute('flavor') === 'sidebar';
18
+ this.#chat = createUranoChat({
19
+ endpoint: this.getAttribute('endpoint') || '/api/urano-social',
20
+ displayName: this.getAttribute('displayname') || undefined,
21
+ onMessages: (msgs) => {
22
+ this.#messages = msgs;
23
+ this.render();
24
+ },
25
+ onError: (err) => {
26
+ this.#error = err.message;
27
+ this.render();
28
+ },
29
+ onStatus: () => this.render(),
30
+ });
31
+ this.render();
32
+ }
33
+ disconnectedCallback() {
34
+ this.#chat?.destroy();
35
+ this.#chat = null;
36
+ }
37
+ attributeChangedCallback() {
38
+ if (!this.#chat)
39
+ return;
40
+ const name = this.getAttribute('displayname');
41
+ if (name)
42
+ this.#chat.setDisplayName(name);
43
+ this.render();
44
+ }
45
+ get chat() {
46
+ return this.#chat;
47
+ }
48
+ render() {
49
+ const flavor = (this.getAttribute('flavor') || 'floating');
50
+ const title = this.getAttribute('title') || 'Atención al cliente';
51
+ const streaming = !!this.#chat?.streaming;
52
+ if (flavor === 'headless') {
53
+ this.#shadow.innerHTML = '';
54
+ return;
55
+ }
56
+ const bubbles = this.#messages.map((m) => `
57
+ <div class="urano-social-bubble" data-role="${m.role}" data-streaming="${m.streaming ? 'true' : 'false'}" part="message">
58
+ <slot name="message-${m.id}">${escapeHtml(m.content)}</slot>
59
+ </div>
60
+ `).join('');
61
+ this.#shadow.innerHTML = `
62
+ <link rel="stylesheet" href="">
63
+ <style>
64
+ @import url("data:text/css,");
65
+ </style>
66
+ <div class="urano-social-host" data-flavor="${flavor}" data-open="${this.#open}">
67
+ ${flavor === 'floating' ? `
68
+ <slot name="launcher">
69
+ <button type="button" class="urano-social-launcher" part="launcher" aria-label="Abrir chat">💬</button>
70
+ </slot>` : ''}
71
+ <div class="urano-social-panel" part="panel">
72
+ <slot name="header">
73
+ <div class="urano-social-header" part="header">
74
+ <h2>${escapeHtml(title)}</h2>
75
+ <button type="button" class="urano-social-close" aria-label="Cerrar">×</button>
76
+ </div>
77
+ </slot>
78
+ <slot name="messages">
79
+ <div class="urano-social-messages" part="messages">
80
+ ${bubbles || `<slot name="empty"><div class="urano-social-empty" part="empty">Escribe tu consulta y te atendemos.</div></slot>`}
81
+ </div>
82
+ </slot>
83
+ ${this.#error ? `<div class="urano-social-error">${escapeHtml(this.#error)}</div>` : ''}
84
+ <slot name="composer">
85
+ <form class="urano-social-composer" part="composer">
86
+ <textarea rows="1" placeholder="Escribe un mensaje…" ${streaming ? 'disabled' : ''}>${escapeHtml(this.#draft)}</textarea>
87
+ <button type="submit" ${streaming ? 'disabled' : ''}>Enviar</button>
88
+ </form>
89
+ </slot>
90
+ </div>
91
+ </div>
92
+ `;
93
+ // Inline critical CSS so the element works without a bundler CSS import.
94
+ const style = document.createElement('style');
95
+ style.textContent = getEmbeddedCss();
96
+ this.#shadow.prepend(style);
97
+ this.#shadow.querySelector('.urano-social-launcher')?.addEventListener('click', () => {
98
+ this.#open = !this.#open;
99
+ this.render();
100
+ });
101
+ this.#shadow.querySelector('.urano-social-close')?.addEventListener('click', () => {
102
+ this.#open = false;
103
+ this.render();
104
+ });
105
+ const form = this.#shadow.querySelector('form');
106
+ const textarea = form?.querySelector('textarea');
107
+ textarea?.addEventListener('input', () => {
108
+ this.#draft = textarea.value;
109
+ });
110
+ form?.addEventListener('submit', async (ev) => {
111
+ ev.preventDefault();
112
+ const text = (textarea?.value || '').trim();
113
+ if (!text || !this.#chat)
114
+ return;
115
+ this.#error = '';
116
+ this.#draft = '';
117
+ try {
118
+ await this.#chat.send(text);
119
+ }
120
+ catch (error) {
121
+ this.#error = error?.message || 'send_failed';
122
+ this.render();
123
+ }
124
+ });
125
+ const scroller = this.#shadow.querySelector('.urano-social-messages');
126
+ if (scroller)
127
+ scroller.scrollTop = scroller.scrollHeight;
128
+ }
129
+ }
130
+ function escapeHtml(value) {
131
+ return String(value)
132
+ .replace(/&/g, '&amp;')
133
+ .replace(/</g, '&lt;')
134
+ .replace(/>/g, '&gt;')
135
+ .replace(/"/g, '&quot;');
136
+ }
137
+ function getEmbeddedCss() {
138
+ // Keep a compact copy for Shadow DOM; full theme also ships as flavors.css.
139
+ return `
140
+ :host { all: initial; }
141
+ .urano-social-host{font-family:ui-sans-serif,system-ui,sans-serif;color:#e8eef6;--us-bg:#0f1419;--us-panel:#1a2332;--us-border:#2a3a4f;--us-accent:#3dba9a;--us-accent-contrast:#06241c;--us-user-bg:#243447;--us-bot-bg:#15202b;--us-danger:#e35d6a;--us-muted:#8fa3b8;--us-radius:14px;--us-shadow:0 18px 50px rgba(0,0,0,.35);--us-z:2147483000}
142
+ .urano-social-host *,.urano-social-host *::before,.urano-social-host *::after{box-sizing:border-box}
143
+ .urano-social-launcher{position:fixed;right:20px;bottom:20px;z-index:var(--us-z);width:56px;height:56px;border:0;border-radius:999px;background:var(--us-accent);color:var(--us-accent-contrast);font-size:22px;cursor:pointer;box-shadow:var(--us-shadow)}
144
+ .urano-social-panel{display:flex;flex-direction:column;background:var(--us-panel);border:1px solid var(--us-border);border-radius:var(--us-radius);box-shadow:var(--us-shadow);overflow:hidden}
145
+ .urano-social-host[data-flavor=floating] .urano-social-panel{position:fixed;right:20px;bottom:88px;z-index:var(--us-z);width:min(380px,calc(100vw - 24px));height:min(560px,calc(100vh - 120px))}
146
+ .urano-social-host[data-flavor=sidebar]{position:fixed;top:0;right:0;z-index:var(--us-z);height:100vh;width:min(380px,100vw)}
147
+ .urano-social-host[data-flavor=sidebar] .urano-social-panel{height:100%;border-radius:0;border-right:0;border-top:0;border-bottom:0}
148
+ .urano-social-header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--us-border);background:var(--us-bg)}
149
+ .urano-social-header h2{margin:0;font-size:15px;font-weight:600}
150
+ .urano-social-header button{border:0;background:transparent;color:var(--us-muted);cursor:pointer;font-size:18px}
151
+ .urano-social-messages{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:10px;background:var(--us-bg)}
152
+ .urano-social-empty{margin:auto;text-align:center;color:var(--us-muted);font-size:14px;padding:24px}
153
+ .urano-social-bubble{max-width:85%;padding:10px 12px;border-radius:12px;font-size:14px;line-height:1.45;white-space:pre-wrap;word-break:break-word}
154
+ .urano-social-bubble[data-role=user]{align-self:flex-end;background:var(--us-user-bg)}
155
+ .urano-social-bubble[data-role=assistant]{align-self:flex-start;background:var(--us-bot-bg);border:1px solid var(--us-border)}
156
+ .urano-social-bubble[data-streaming=true]::after{content:"▍";margin-left:2px;animation:us-blink 1s steps(2,jump-none) infinite}
157
+ @keyframes us-blink{to{opacity:0}}
158
+ .urano-social-composer{display:flex;gap:8px;padding:12px;border-top:1px solid var(--us-border);background:var(--us-panel)}
159
+ .urano-social-composer textarea{flex:1;resize:none;min-height:42px;border-radius:10px;border:1px solid var(--us-border);background:var(--us-bg);color:#e8eef6;padding:10px 12px;font:inherit}
160
+ .urano-social-composer button{border:0;border-radius:10px;background:var(--us-accent);color:var(--us-accent-contrast);padding:0 14px;font-weight:600;cursor:pointer}
161
+ .urano-social-composer button:disabled{opacity:.55;cursor:not-allowed}
162
+ .urano-social-error{color:var(--us-danger);font-size:12px;padding:0 12px 8px}
163
+ .urano-social-host[data-open=false] .urano-social-panel{display:none}
164
+ .urano-social-host[data-flavor=sidebar][data-open=false]{display:none}
165
+ `;
166
+ }
167
+ export function registerUranoSocialChat(tagName = 'urano-social-chat') {
168
+ if (typeof customElements === 'undefined')
169
+ return;
170
+ if (!customElements.get(tagName)) {
171
+ customElements.define(tagName, UranoSocialChatElement);
172
+ }
173
+ }
@@ -0,0 +1,9 @@
1
+ export declare class UranoSocialContactFormElement extends HTMLElement {
2
+ #private;
3
+ static get observedAttributes(): string[];
4
+ constructor();
5
+ connectedCallback(): void;
6
+ attributeChangedCallback(): void;
7
+ render(): void;
8
+ }
9
+ export declare function registerUranoSocialContactForm(tagName?: string): void;
@@ -0,0 +1,103 @@
1
+ import { submitContactForm } from '../client/submitContactForm';
2
+ import { DEFAULT_HONEYPOT_FIELD } from '../types';
3
+ export class UranoSocialContactFormElement extends HTMLElement {
4
+ static get observedAttributes() {
5
+ return ['endpoint', 'title', 'submit-label', 'recaptcha-site-key', 'honeypot-field'];
6
+ }
7
+ #shadow;
8
+ #busy = false;
9
+ #error = '';
10
+ #success = '';
11
+ constructor() {
12
+ super();
13
+ this.#shadow = this.attachShadow({ mode: 'open' });
14
+ }
15
+ connectedCallback() {
16
+ this.render();
17
+ }
18
+ attributeChangedCallback() {
19
+ this.render();
20
+ }
21
+ render() {
22
+ const title = this.getAttribute('title') || 'Contáctanos';
23
+ const submitLabel = this.getAttribute('submit-label') || 'Enviar';
24
+ const honeypotField = this.getAttribute('honeypot-field') || DEFAULT_HONEYPOT_FIELD;
25
+ this.#shadow.innerHTML = `
26
+ <style>${css}</style>
27
+ <form class="us-contact" part="form">
28
+ <slot name="header"><h3 part="title">${escapeHtml(title)}</h3></slot>
29
+ <label>Nombre<input name="name" type="text" autocomplete="name" /></label>
30
+ <label>Email<input name="email" type="email" required autocomplete="email" /></label>
31
+ <label>Asunto<input name="subject" type="text" /></label>
32
+ <label>Mensaje<textarea name="message" rows="4" required></textarea></label>
33
+ <label class="hp" aria-hidden="true">${escapeHtml(honeypotField)}
34
+ <input name="${escapeHtml(honeypotField)}" type="text" tabindex="-1" autocomplete="off" />
35
+ </label>
36
+ ${this.#error ? `<p class="err" part="error">${escapeHtml(this.#error)}</p>` : ''}
37
+ ${this.#success ? `<p class="ok" part="success">${escapeHtml(this.#success)}</p>` : ''}
38
+ <button type="submit" ${this.#busy ? 'disabled' : ''} part="submit">${escapeHtml(submitLabel)}</button>
39
+ </form>
40
+ `;
41
+ const form = this.#shadow.querySelector('form');
42
+ form?.addEventListener('submit', async (ev) => {
43
+ ev.preventDefault();
44
+ if (this.#busy)
45
+ return;
46
+ const data = new FormData(form);
47
+ this.#busy = true;
48
+ this.#error = '';
49
+ this.#success = '';
50
+ this.render();
51
+ try {
52
+ const result = await submitContactForm({
53
+ name: String(data.get('name') || ''),
54
+ email: String(data.get('email') || ''),
55
+ subject: String(data.get('subject') || ''),
56
+ message: String(data.get('message') || ''),
57
+ website: String(data.get(honeypotField) || ''),
58
+ }, {
59
+ endpoint: this.getAttribute('endpoint') || '/api/urano-social',
60
+ recaptchaSiteKey: this.getAttribute('recaptcha-site-key') || undefined,
61
+ });
62
+ if (!result.ok)
63
+ throw new Error(result.error || 'send_failed');
64
+ this.#success = result.text
65
+ ? 'Enviado. Te responderemos por correo.'
66
+ : 'Enviado. Revisa tu bandeja de entrada.';
67
+ }
68
+ catch (e) {
69
+ this.#error = e?.message || 'send_failed';
70
+ }
71
+ finally {
72
+ this.#busy = false;
73
+ this.render();
74
+ }
75
+ });
76
+ }
77
+ }
78
+ function escapeHtml(value) {
79
+ return String(value)
80
+ .replace(/&/g, '&amp;')
81
+ .replace(/</g, '&lt;')
82
+ .replace(/>/g, '&gt;')
83
+ .replace(/"/g, '&quot;');
84
+ }
85
+ const css = `
86
+ :host{all:initial;display:block;font-family:ui-sans-serif,system-ui,sans-serif;color:#e8eef6}
87
+ .us-contact{display:flex;flex-direction:column;gap:10px;padding:16px;background:#1a2332;border:1px solid #2a3a4f;border-radius:14px;max-width:420px;position:relative}
88
+ h3{margin:0 0 4px;font-size:16px}
89
+ label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:#8fa3b8}
90
+ label.hp{position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden}
91
+ input,textarea{border:1px solid #2a3a4f;border-radius:10px;background:#0f1419;color:#e8eef6;padding:10px 12px;font:inherit}
92
+ button{border:0;border-radius:10px;background:#3dba9a;color:#06241c;padding:10px 14px;font-weight:600;cursor:pointer}
93
+ button:disabled{opacity:.55;cursor:not-allowed}
94
+ .err{color:#e35d6a;font-size:12px;margin:0}
95
+ .ok{color:#3dba9a;font-size:12px;margin:0}
96
+ `;
97
+ export function registerUranoSocialContactForm(tagName = 'urano-social-contact') {
98
+ if (typeof customElements === 'undefined')
99
+ return;
100
+ if (!customElements.get(tagName)) {
101
+ customElements.define(tagName, UranoSocialContactFormElement);
102
+ }
103
+ }