@momentco-ai/moment-sdk 0.1.0-dev.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moment Co.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # Moment SDK
2
+
3
+ Embeddable calendar sync widget for external team websites. Zero runtime dependencies.
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ Add the SDK via a script tag:
12
+
13
+ ```html
14
+ <script src="https://unpkg.com/@momentco-ai/moment-sdk"></script>
15
+ ```
16
+
17
+ Or via npm:
18
+
19
+ ```bash
20
+ npm install @momentco-ai/moment-sdk
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Quick Start
26
+
27
+ ```html
28
+ <!-- 1. Add trigger buttons with data attributes -->
29
+ <button
30
+ class="moment-sync-trigger"
31
+ data-moment-team-slug="your-team-slug"
32
+ data-external-event-id="event-001"
33
+ data-moment-trigger-type="moment"
34
+ >
35
+ Add to Calendar
36
+ </button>
37
+
38
+ <!-- 2. Load the SDK and initialize -->
39
+ <script src="https://unpkg.com/@momentco-ai/moment-sdk"></script>
40
+ <script>
41
+ const sdk = new MomentSdk({
42
+ onSuccess: (result) => console.log('Synced!', result),
43
+ onError: (result) => console.error('Failed', result),
44
+ onClose: (result) => console.log('Closed', result),
45
+ });
46
+ </script>
47
+ ```
48
+
49
+ That's it — clicking the button opens a modal where users can sync the event to Google Calendar or Outlook.
50
+
51
+ See [`examples/basic/`](./examples/basic/) for a complete working example.
52
+
53
+ ---
54
+
55
+ ## Init Options
56
+
57
+ | Option | Type | Required | Description |
58
+ | ----------------- | ------------------- | -------- | ------------------------------------------------------------------ |
59
+ | `triggerSelector` | `string` | No | CSS selector for trigger elements. Default: `.moment-sync-trigger` |
60
+ | `onSuccess` | `(payload) => void` | No | Called on successful calendar sync |
61
+ | `onError` | `(payload) => void` | No | Called on sync failure |
62
+ | `onClose` | `(payload) => void` | No | Called when modal closes (success, error, or cancel) |
63
+ | `onAnalytics` | `(event) => void` | No | Called for every analytics event |
64
+
65
+ ---
66
+
67
+ ## Data Attributes
68
+
69
+ Place these on any element matching the trigger selector (default: `.moment-sync-trigger`):
70
+
71
+ | Attribute | Required | Description |
72
+ | -------------------------- | -------- | ------------------------------------------------------------ |
73
+ | `data-moment-team-slug` | ✅ | Team/brand slug |
74
+ | `data-external-event-id` | No | Specific event identifier (resolved to real ID by Moment) |
75
+ | `data-moment-list-slug` | No | List slug (resolved to moment IDs by Moment) |
76
+ | `data-moment-trigger-type` | No | `moment` (default), `list`, or `schedule` |
77
+ | `data-moment-ids` | No | Comma-separated moment IDs (fallback if slugs don't resolve) |
78
+ | `data-moment-game-id` | No | Alias for `data-moment-ids` |
79
+ | `data-moment-calendar` | No | Pre-select provider: `google` or `outlook` |
80
+
81
+ ---
82
+
83
+ ## Programmatic API
84
+
85
+ You can also open the modal programmatically instead of relying on data attributes:
86
+
87
+ ```html
88
+ <script>
89
+ const sdk = new MomentSdk({
90
+ /* callbacks */
91
+ });
92
+
93
+ // Open modal
94
+ sdk.open({
95
+ teamSlug: 'your-team-slug',
96
+ externalEventId: 'event-123',
97
+ triggerType: 'moment',
98
+ ids: ['moment-id-1'],
99
+ calendar: 'google', // optional: skip provider selection
100
+ });
101
+
102
+ // Close modal
103
+ sdk.close();
104
+
105
+ // Re-bind triggers after dynamic DOM updates
106
+ sdk.rebind();
107
+
108
+ // Destroy instance entirely
109
+ sdk.destroy();
110
+ </script>
111
+ ```
112
+
113
+ ---
114
+
115
+ ## `createMomentButton()`
116
+
117
+ Programmatically create a styled trigger button:
118
+
119
+ ```html
120
+ <script>
121
+ const btn = createMomentButton({
122
+ teamSlug: 'your-team-slug',
123
+ externalEventId: 'event-001',
124
+ triggerType: 'moment',
125
+ label: 'Add to Calendar',
126
+ });
127
+
128
+ document.getElementById('container').appendChild(btn);
129
+ </script>
130
+ ```
131
+
132
+ The button renders with Moment's default styling (stone-900 background, subtle border, rounded corners). Override with CSS or pass a `className` option.
133
+
134
+ ---
135
+
136
+ ## Analytics Events
137
+
138
+ The `onAnalytics` callback receives events with these names:
139
+
140
+ | Event | When |
141
+ | --------------------- | ----------------------------------- |
142
+ | `embed.init` | SDK initialized |
143
+ | `embed.trigger.click` | User clicked a trigger button |
144
+ | `embed.open` | Modal iframe opened |
145
+ | `oauth.start` | OAuth popup requested |
146
+ | `oauth.popup.blocked` | Browser blocked the popup |
147
+ | `oauth.popup.closed` | User closed popup before completing |
148
+ | `subscribe.success` | Calendar sync succeeded |
149
+ | `subscribe.error` | Calendar sync failed |
150
+ | `embed.close` | Modal closed |
151
+
152
+ ---
153
+
154
+ ## Result Payload
155
+
156
+ All callbacks (`onSuccess`, `onError`, `onClose`) receive a result payload:
157
+
158
+ ```json
159
+ {
160
+ "source": "moment-live-embed",
161
+ "type": "moment.embed.result",
162
+ "result": "success | error | cancelled",
163
+ "provider": "google | outlook",
164
+ "triggerType": "moment | list | schedule",
165
+ "momentIds": ["..."],
166
+ "message": "optional details"
167
+ }
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Examples
173
+
174
+ See the [`examples/basic/`](./examples/basic/) directory for a complete standalone integration example.
175
+
176
+ ---
177
+
178
+ ## Contributing
179
+
180
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and guidelines.
181
+
182
+ ## Security
183
+
184
+ See [SECURITY.md](./SECURITY.md) for reporting vulnerabilities.
185
+
186
+ ## License
187
+
188
+ [MIT](./LICENSE) — Moment Co.
@@ -0,0 +1 @@
1
+ (function(u){"use strict";class g{popup=null;pollTimer=null;messageHandler=null;resultReceived=!1;opts;constructor(e){this.opts=e}open(e){this.cleanup(),this.resultReceived=!1;const t=this.buildPopupUrl(e),s=Math.round(window.screenX+(window.outerWidth-500)/2),r=Math.round(window.screenY+(window.outerHeight-700)/2);if(this.popup=window.open(t,"moment-oauth-popup",`width=500,height=700,left=${s},top=${r},menubar=no,toolbar=no,location=yes,status=no`),!this.popup||this.popup.closed){this.sendStatusToIframe("popup-blocked"),this.opts.onPopupBlocked();return}this.sendStatusToIframe("popup-opened"),this.startPolling(),this.messageHandler=i=>{if(i.origin!==this.opts.liveOrigin||i.source!==this.popup)return;const o=i.data;!o||typeof o!="object"||o.source!=="moment-live-embed"||o.type!=="moment.embed.result"||(this.resultReceived=!0,this.sendStatusToIframe("completed"),this.opts.iframeWindow&&this.opts.iframeWindow.postMessage(o,this.opts.liveOrigin),this.opts.onResult(o),this.cleanup())},window.addEventListener("message",this.messageHandler)}cleanup(){if(this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.messageHandler&&(window.removeEventListener("message",this.messageHandler),this.messageHandler=null),this.popup&&!this.popup.closed)try{this.popup.close()}catch{}this.popup=null}buildPopupUrl(e){const t=window.location.origin,s=new URLSearchParams({provider:e.provider,teamSlug:e.teamSlug,ids:e.ids.join(","),subscriptionType:e.triggerType,returnOrigin:t});return`${this.opts.liveBaseUrl}/en/embed/oauth-popup?${s.toString()}`}startPolling(){this.pollTimer=setInterval(()=>{(!this.popup||this.popup.closed)&&(this.pollTimer&&(clearInterval(this.pollTimer),this.pollTimer=null),this.resultReceived||(this.sendStatusToIframe("popup-closed"),this.opts.onPopupClosed()),this.cleanup())},500)}sendStatusToIframe(e){if(!this.opts.iframeWindow)return;const t={source:"moment-sdk",type:"moment.embed.oauth.status",status:e};this.opts.iframeWindow.postMessage(t,this.opts.liveOrigin)}}const f=".moment-sync-trigger",v="https://live.dev.momentco.ai";class m{opts;liveBaseUrl;liveOrigin;overlay=null;iframe=null;popupBridge=null;messageHandler=null;boundClickHandlers=new Map;activePayload=null;previousFocusedElement=null;previousBodyOverflow="";didManageBodyOverflow=!1;closeTimer=null;constructor(e){this.opts={triggerSelector:f,...e},this.liveBaseUrl=v;try{this.liveOrigin=new URL(this.liveBaseUrl).origin}catch{throw new Error(`[MomentSdk] Invalid VITE_LIVE_BASE_URL: "${this.liveBaseUrl}"`)}this.bindTriggers(),this.emitAnalytics("embed.init")}open(e){this.emitAnalytics("embed.open",{teamSlug:e.teamSlug,triggerType:e.triggerType}),this.createModal(e)}close(){this.handleCancel()}rebind(){this.unbindTriggers(),this.bindTriggers()}destroy(){this.cleanup(),this.unbindTriggers()}bindTriggers(){document.querySelectorAll(this.opts.triggerSelector).forEach(t=>{const s=i=>{i.preventDefault(),this.handleTriggerClick(t)},r=this.boundClickHandlers.get(t);r&&t.removeEventListener("click",r),t.addEventListener("click",s),this.boundClickHandlers.set(t,s)})}unbindTriggers(){this.boundClickHandlers.forEach((e,t)=>{t.removeEventListener("click",e)}),this.boundClickHandlers.clear()}handleTriggerClick(e){const t=e.getAttribute("data-moment-team-slug")??"",s=e.getAttribute("data-external-event-id")??void 0,r=e.getAttribute("data-moment-list-slug")??void 0,i=e.getAttribute("data-moment-trigger-type");let o="moment";i!=null&&i!==""&&(i==="moment"||i==="list"||i==="schedule"?o=i:console.warn("[MomentSdk] Invalid data-moment-trigger-type, falling back to 'moment':",i));const l=e.getAttribute("data-moment-ids")??e.getAttribute("data-moment-game-id")??"",d=l?l.split(",").filter(Boolean):[],a=e.getAttribute("data-moment-calendar");let c;if(a!=null&&a!==""&&(a==="google"||a==="outlook"?c=a:console.warn("[MomentSdk] Invalid data-moment-calendar, ignoring value:",a)),!t){console.warn("[MomentSdk] Missing data-moment-team-slug on trigger element");return}this.emitAnalytics("embed.trigger.click",{teamSlug:t,triggerType:o,momentIds:d}),this.open({teamSlug:t,externalEventId:s,listSlug:r,triggerType:o,ids:d,calendar:c??void 0})}createModal(e){this.cleanup(),this.previousFocusedElement=document.activeElement||null,this.activePayload=e;const t=this.buildIframeUrl(e);this.overlay=document.createElement("div"),this.overlay.setAttribute("role","presentation"),Object.assign(this.overlay.style,{position:"fixed",inset:"0",zIndex:"999999",display:"flex",alignItems:"center",justifyContent:"center",backgroundColor:"rgba(0, 0, 0, 0.5)",backdropFilter:"blur(2px)",padding:"16px"}),this.overlay.addEventListener("click",l=>{l.target===this.overlay&&this.handleCancel()});const s=l=>{l.key==="Escape"&&this.handleCancel()};document.addEventListener("keydown",s);const r=document.createElement("div");r.setAttribute("role","dialog"),r.setAttribute("aria-modal","true"),r.setAttribute("aria-label","Calendar sync modal"),r.tabIndex=-1,Object.assign(r.style,{position:"relative",width:"100%",maxWidth:"420px",maxHeight:"90vh",borderRadius:"16px",overflow:"hidden",backgroundColor:"#ffffff",boxShadow:"0 25px 50px -12px rgba(0, 0, 0, 0.25)"});const i=document.createElement("button");i.type="button",i.textContent="ⓧ",i.setAttribute("aria-label","Close"),Object.assign(i.style,{position:"absolute",top:"8px",right:"8px",zIndex:"10",border:"none",backgroundColor:"transparent",color:"#999",fontSize:"20px",lineHeight:"1",cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center"}),i.addEventListener("click",()=>this.handleCancel()),this.iframe=document.createElement("iframe"),this.iframe.src=t,this.iframe.setAttribute("allow",""),this.iframe.setAttribute("sandbox","allow-scripts allow-same-origin allow-popups allow-forms"),Object.assign(this.iframe.style,{width:"100%",height:"480px",border:"none",display:"block"}),r.appendChild(i),r.appendChild(this.iframe),this.overlay.appendChild(r),document.body.appendChild(this.overlay);const o=l=>{if(l.key!=="Tab")return;const d=r.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');if(d.length===0){l.preventDefault(),r.focus();return}const a=d[0],c=d[d.length-1],h=document.activeElement;l.shiftKey&&h===a?(l.preventDefault(),c.focus()):!l.shiftKey&&h===c&&(l.preventDefault(),a.focus())};r.addEventListener("keydown",o),i.focus(),this.previousBodyOverflow=document.body.style.overflow,document.body.style.overflow="hidden",this.didManageBodyOverflow=!0,this.setupMessageListener(),this.overlay._escHandler=s,this.overlay._focusTrapHandler=o}buildIframeUrl(e){const t=new URLSearchParams;return t.set("teamSlug",e.teamSlug),e.externalEventId&&t.set("momentSlug",e.externalEventId),e.listSlug&&t.set("listSlug",e.listSlug),t.set("subscriptionType",e.triggerType),e.ids?.length&&t.set("ids",e.ids.join(",")),e.calendar&&t.set("calendar",e.calendar),t.set("returnOrigin",window.location.origin),t.set("embed","1"),`${this.liveBaseUrl}/en/embed/sync?${t.toString()}`}setupMessageListener(){this.messageHandler=e=>{if(e.origin!==this.liveOrigin||e.source!==this.iframe?.contentWindow)return;const t=e.data;if(!(!t||typeof t!="object")){if(t.source==="moment-live-embed"&&t.type==="moment.embed.oauth.start"){const s=t;this.emitAnalytics("oauth.start",{provider:s.provider,teamSlug:s.teamSlug}),this.handleOAuthStart(s);return}if(t.source==="moment-live-embed"&&t.type==="moment.embed.result"){const s=t;this.handleResult(s)}}},window.addEventListener("message",this.messageHandler)}handleOAuthStart(e){this.popupBridge&&(this.popupBridge.cleanup(),this.popupBridge=null),this.popupBridge=new g({liveBaseUrl:this.liveBaseUrl,liveOrigin:this.liveOrigin,iframeWindow:this.iframe?.contentWindow??null,onResult:t=>{this.handleResult(t)},onPopupBlocked:()=>{this.emitAnalytics("oauth.popup.blocked",{provider:e.provider})},onPopupClosed:()=>{this.emitAnalytics("oauth.popup.closed",{provider:e.provider})}}),this.popupBridge.open(e)}handleResult(e){if(e.result==="success")this.emitAnalytics("subscribe.success",{provider:e.provider,triggerType:e.triggerType,momentIds:e.momentIds}),this.opts.onSuccess?.(e);else if(e.result==="error")this.emitAnalytics("subscribe.error",{provider:e.provider,triggerType:e.triggerType,momentIds:e.momentIds}),this.opts.onError?.(e);else if(e.result==="cancelled")return;this.opts.onClose?.(e),this.emitAnalytics("embed.close"),this.closeTimer=setTimeout(()=>this.cleanup(),1500)}handleCancel(){this.emitAnalytics("embed.close");const e={source:"moment-live-embed",type:"moment.embed.result",result:"cancelled",triggerType:this.activePayload?.triggerType??"moment",momentIds:this.activePayload?.ids??[]};this.opts.onClose?.(e),this.cleanup()}cleanup(){if(this.closeTimer&&(clearTimeout(this.closeTimer),this.closeTimer=null),this.popupBridge&&(this.popupBridge.cleanup(),this.popupBridge=null),this.messageHandler&&(window.removeEventListener("message",this.messageHandler),this.messageHandler=null),this.overlay){const{_escHandler:e,_focusTrapHandler:t}=this.overlay;e&&document.removeEventListener("keydown",e);const s=this.overlay.firstElementChild;s&&t&&s.removeEventListener("keydown",t),this.overlay.remove(),this.overlay=null}this.iframe=null,this.activePayload=null,this.didManageBodyOverflow&&(document.body.style.overflow=this.previousBodyOverflow,this.didManageBodyOverflow=!1),this.previousFocusedElement&&typeof this.previousFocusedElement.focus=="function"&&this.previousFocusedElement.focus(),this.previousFocusedElement=null}emitAnalytics(e,t){this.opts.onAnalytics?.({event:e,timestamp:Date.now(),...t})}}function p(n){const e=document.createElement("button");return e.type="button",e.classList.add("moment-sync-trigger"),n.className&&n.className.split(" ").forEach(t=>{t&&e.classList.add(t)}),Object.assign(e.style,{backgroundColor:"#1c1917",color:"#ffffff",border:"1px solid #292524",borderRadius:"4px",padding:"8px 16px",fontSize:"14px",fontWeight:"500",cursor:"pointer",lineHeight:"1.4"}),e.setAttribute("data-moment-team-slug",n.teamSlug),n.externalEventId&&e.setAttribute("data-external-event-id",n.externalEventId),n.listSlug&&e.setAttribute("data-moment-list-slug",n.listSlug),n.triggerType&&e.setAttribute("data-moment-trigger-type",n.triggerType),n.ids?.length&&e.setAttribute("data-moment-ids",n.ids.join(",")),n.calendar&&e.setAttribute("data-moment-calendar",n.calendar),e.textContent=n.label??"Add to Calendar",e}if(typeof window<"u"){const n=window;n.MomentSdk=m,n.createMomentButton=p}u.MomentSdk=m,u.createMomentButton=p,Object.defineProperty(u,Symbol.toStringTag,{value:"Module"})})(this.MomentSdk=this.MomentSdk||{});
@@ -0,0 +1,342 @@
1
+ class m {
2
+ popup = null;
3
+ pollTimer = null;
4
+ messageHandler = null;
5
+ resultReceived = !1;
6
+ opts;
7
+ constructor(e) {
8
+ this.opts = e;
9
+ }
10
+ /**
11
+ * Open the OAuth popup in response to a `moment.embed.oauth.start` message.
12
+ */
13
+ open(e) {
14
+ this.cleanup(), this.resultReceived = !1;
15
+ const t = this.buildPopupUrl(e), s = Math.round(window.screenX + (window.outerWidth - 500) / 2), r = Math.round(window.screenY + (window.outerHeight - 700) / 2);
16
+ if (this.popup = window.open(
17
+ t,
18
+ "moment-oauth-popup",
19
+ `width=500,height=700,left=${s},top=${r},menubar=no,toolbar=no,location=yes,status=no`
20
+ ), !this.popup || this.popup.closed) {
21
+ this.sendStatusToIframe("popup-blocked"), this.opts.onPopupBlocked();
22
+ return;
23
+ }
24
+ this.sendStatusToIframe("popup-opened"), this.startPolling(), this.messageHandler = (i) => {
25
+ if (i.origin !== this.opts.liveOrigin || i.source !== this.popup) return;
26
+ const o = i.data;
27
+ !o || typeof o != "object" || o.source !== "moment-live-embed" || o.type !== "moment.embed.result" || (this.resultReceived = !0, this.sendStatusToIframe("completed"), this.opts.iframeWindow && this.opts.iframeWindow.postMessage(o, this.opts.liveOrigin), this.opts.onResult(o), this.cleanup());
28
+ }, window.addEventListener("message", this.messageHandler);
29
+ }
30
+ /**
31
+ * Clean up popup, polling, and event listeners.
32
+ */
33
+ cleanup() {
34
+ if (this.pollTimer && (clearInterval(this.pollTimer), this.pollTimer = null), this.messageHandler && (window.removeEventListener("message", this.messageHandler), this.messageHandler = null), this.popup && !this.popup.closed)
35
+ try {
36
+ this.popup.close();
37
+ } catch {
38
+ }
39
+ this.popup = null;
40
+ }
41
+ buildPopupUrl(e) {
42
+ const t = window.location.origin, s = new URLSearchParams({
43
+ provider: e.provider,
44
+ teamSlug: e.teamSlug,
45
+ ids: e.ids.join(","),
46
+ subscriptionType: e.triggerType,
47
+ returnOrigin: t
48
+ });
49
+ return `${this.opts.liveBaseUrl}/en/embed/oauth-popup?${s.toString()}`;
50
+ }
51
+ startPolling() {
52
+ this.pollTimer = setInterval(() => {
53
+ (!this.popup || this.popup.closed) && (this.pollTimer && (clearInterval(this.pollTimer), this.pollTimer = null), this.resultReceived || (this.sendStatusToIframe("popup-closed"), this.opts.onPopupClosed()), this.cleanup());
54
+ }, 500);
55
+ }
56
+ sendStatusToIframe(e) {
57
+ if (!this.opts.iframeWindow) return;
58
+ const t = {
59
+ source: "moment-sdk",
60
+ type: "moment.embed.oauth.status",
61
+ status: e
62
+ };
63
+ this.opts.iframeWindow.postMessage(t, this.opts.liveOrigin);
64
+ }
65
+ }
66
+ const p = ".moment-sync-trigger", h = "https://live.dev.momentco.ai";
67
+ class g {
68
+ opts;
69
+ liveBaseUrl;
70
+ liveOrigin;
71
+ overlay = null;
72
+ iframe = null;
73
+ popupBridge = null;
74
+ messageHandler = null;
75
+ boundClickHandlers = /* @__PURE__ */ new Map();
76
+ activePayload = null;
77
+ previousFocusedElement = null;
78
+ previousBodyOverflow = "";
79
+ didManageBodyOverflow = !1;
80
+ closeTimer = null;
81
+ constructor(e) {
82
+ this.opts = {
83
+ triggerSelector: p,
84
+ ...e
85
+ }, this.liveBaseUrl = h;
86
+ try {
87
+ this.liveOrigin = new URL(this.liveBaseUrl).origin;
88
+ } catch {
89
+ throw new Error(`[MomentSdk] Invalid VITE_LIVE_BASE_URL: "${this.liveBaseUrl}"`);
90
+ }
91
+ this.bindTriggers(), this.emitAnalytics("embed.init");
92
+ }
93
+ /**
94
+ * Programmatically open the sync modal.
95
+ */
96
+ open(e) {
97
+ this.emitAnalytics("embed.open", {
98
+ teamSlug: e.teamSlug,
99
+ triggerType: e.triggerType
100
+ }), this.createModal(e);
101
+ }
102
+ /**
103
+ * Close the modal and clean up all resources.
104
+ */
105
+ close() {
106
+ this.handleCancel();
107
+ }
108
+ /**
109
+ * Re-bind trigger elements (call after dynamic DOM updates).
110
+ */
111
+ rebind() {
112
+ this.unbindTriggers(), this.bindTriggers();
113
+ }
114
+ /**
115
+ * Destroy the SDK instance, removing all event listeners and DOM.
116
+ */
117
+ destroy() {
118
+ this.cleanup(), this.unbindTriggers();
119
+ }
120
+ // ── Private: trigger binding ──────────────────────────────────────
121
+ bindTriggers() {
122
+ document.querySelectorAll(this.opts.triggerSelector).forEach((t) => {
123
+ const s = (i) => {
124
+ i.preventDefault(), this.handleTriggerClick(t);
125
+ }, r = this.boundClickHandlers.get(t);
126
+ r && t.removeEventListener("click", r), t.addEventListener("click", s), this.boundClickHandlers.set(t, s);
127
+ });
128
+ }
129
+ unbindTriggers() {
130
+ this.boundClickHandlers.forEach((e, t) => {
131
+ t.removeEventListener("click", e);
132
+ }), this.boundClickHandlers.clear();
133
+ }
134
+ handleTriggerClick(e) {
135
+ const t = e.getAttribute("data-moment-team-slug") ?? "", s = e.getAttribute("data-external-event-id") ?? void 0, r = e.getAttribute("data-moment-list-slug") ?? void 0, i = e.getAttribute("data-moment-trigger-type");
136
+ let o = "moment";
137
+ i != null && i !== "" && (i === "moment" || i === "list" || i === "schedule" ? o = i : console.warn(
138
+ "[MomentSdk] Invalid data-moment-trigger-type, falling back to 'moment':",
139
+ i
140
+ ));
141
+ const l = e.getAttribute("data-moment-ids") ?? e.getAttribute("data-moment-game-id") ?? "", d = l ? l.split(",").filter(Boolean) : [], a = e.getAttribute("data-moment-calendar");
142
+ let c;
143
+ if (a != null && a !== "" && (a === "google" || a === "outlook" ? c = a : console.warn("[MomentSdk] Invalid data-moment-calendar, ignoring value:", a)), !t) {
144
+ console.warn("[MomentSdk] Missing data-moment-team-slug on trigger element");
145
+ return;
146
+ }
147
+ this.emitAnalytics("embed.trigger.click", {
148
+ teamSlug: t,
149
+ triggerType: o,
150
+ momentIds: d
151
+ }), this.open({
152
+ teamSlug: t,
153
+ externalEventId: s,
154
+ listSlug: r,
155
+ triggerType: o,
156
+ ids: d,
157
+ calendar: c ?? void 0
158
+ });
159
+ }
160
+ // ── Private: modal creation ───────────────────────────────────────
161
+ createModal(e) {
162
+ this.cleanup(), this.previousFocusedElement = document.activeElement || null, this.activePayload = e;
163
+ const t = this.buildIframeUrl(e);
164
+ this.overlay = document.createElement("div"), this.overlay.setAttribute("role", "presentation"), Object.assign(this.overlay.style, {
165
+ position: "fixed",
166
+ inset: "0",
167
+ zIndex: "999999",
168
+ display: "flex",
169
+ alignItems: "center",
170
+ justifyContent: "center",
171
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
172
+ backdropFilter: "blur(2px)",
173
+ padding: "16px"
174
+ }), this.overlay.addEventListener("click", (l) => {
175
+ l.target === this.overlay && this.handleCancel();
176
+ });
177
+ const s = (l) => {
178
+ l.key === "Escape" && this.handleCancel();
179
+ };
180
+ document.addEventListener("keydown", s);
181
+ const r = document.createElement("div");
182
+ r.setAttribute("role", "dialog"), r.setAttribute("aria-modal", "true"), r.setAttribute("aria-label", "Calendar sync modal"), r.tabIndex = -1, Object.assign(r.style, {
183
+ position: "relative",
184
+ width: "100%",
185
+ maxWidth: "420px",
186
+ maxHeight: "90vh",
187
+ borderRadius: "16px",
188
+ overflow: "hidden",
189
+ backgroundColor: "#ffffff",
190
+ boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)"
191
+ });
192
+ const i = document.createElement("button");
193
+ i.type = "button", i.textContent = "ⓧ", i.setAttribute("aria-label", "Close"), Object.assign(i.style, {
194
+ position: "absolute",
195
+ top: "8px",
196
+ right: "8px",
197
+ zIndex: "10",
198
+ border: "none",
199
+ backgroundColor: "transparent",
200
+ color: "#999",
201
+ fontSize: "20px",
202
+ lineHeight: "1",
203
+ cursor: "pointer",
204
+ display: "flex",
205
+ alignItems: "center",
206
+ justifyContent: "center"
207
+ }), i.addEventListener("click", () => this.handleCancel()), this.iframe = document.createElement("iframe"), this.iframe.src = t, this.iframe.setAttribute("allow", ""), this.iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-popups allow-forms"), Object.assign(this.iframe.style, {
208
+ width: "100%",
209
+ height: "480px",
210
+ border: "none",
211
+ display: "block"
212
+ }), r.appendChild(i), r.appendChild(this.iframe), this.overlay.appendChild(r), document.body.appendChild(this.overlay);
213
+ const o = (l) => {
214
+ if (l.key !== "Tab") return;
215
+ const d = r.querySelectorAll(
216
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
217
+ );
218
+ if (d.length === 0) {
219
+ l.preventDefault(), r.focus();
220
+ return;
221
+ }
222
+ const a = d[0], c = d[d.length - 1], u = document.activeElement;
223
+ l.shiftKey && u === a ? (l.preventDefault(), c.focus()) : !l.shiftKey && u === c && (l.preventDefault(), a.focus());
224
+ };
225
+ r.addEventListener("keydown", o), i.focus(), this.previousBodyOverflow = document.body.style.overflow, document.body.style.overflow = "hidden", this.didManageBodyOverflow = !0, this.setupMessageListener(), this.overlay._escHandler = s, this.overlay._focusTrapHandler = o;
226
+ }
227
+ buildIframeUrl(e) {
228
+ const t = new URLSearchParams();
229
+ return t.set("teamSlug", e.teamSlug), e.externalEventId && t.set("momentSlug", e.externalEventId), e.listSlug && t.set("listSlug", e.listSlug), t.set("subscriptionType", e.triggerType), e.ids?.length && t.set("ids", e.ids.join(",")), e.calendar && t.set("calendar", e.calendar), t.set("returnOrigin", window.location.origin), t.set("embed", "1"), `${this.liveBaseUrl}/en/embed/sync?${t.toString()}`;
230
+ }
231
+ // ── Private: message handling ─────────────────────────────────────
232
+ setupMessageListener() {
233
+ this.messageHandler = (e) => {
234
+ if (e.origin !== this.liveOrigin || e.source !== this.iframe?.contentWindow) return;
235
+ const t = e.data;
236
+ if (!(!t || typeof t != "object")) {
237
+ if (t.source === "moment-live-embed" && t.type === "moment.embed.oauth.start") {
238
+ const s = t;
239
+ this.emitAnalytics("oauth.start", {
240
+ provider: s.provider,
241
+ teamSlug: s.teamSlug
242
+ }), this.handleOAuthStart(s);
243
+ return;
244
+ }
245
+ if (t.source === "moment-live-embed" && t.type === "moment.embed.result") {
246
+ const s = t;
247
+ this.handleResult(s);
248
+ }
249
+ }
250
+ }, window.addEventListener("message", this.messageHandler);
251
+ }
252
+ handleOAuthStart(e) {
253
+ this.popupBridge && (this.popupBridge.cleanup(), this.popupBridge = null), this.popupBridge = new m({
254
+ liveBaseUrl: this.liveBaseUrl,
255
+ liveOrigin: this.liveOrigin,
256
+ iframeWindow: this.iframe?.contentWindow ?? null,
257
+ onResult: (t) => {
258
+ this.handleResult(t);
259
+ },
260
+ onPopupBlocked: () => {
261
+ this.emitAnalytics("oauth.popup.blocked", {
262
+ provider: e.provider
263
+ });
264
+ },
265
+ onPopupClosed: () => {
266
+ this.emitAnalytics("oauth.popup.closed", {
267
+ provider: e.provider
268
+ });
269
+ }
270
+ }), this.popupBridge.open(e);
271
+ }
272
+ handleResult(e) {
273
+ if (e.result === "success")
274
+ this.emitAnalytics("subscribe.success", {
275
+ provider: e.provider,
276
+ triggerType: e.triggerType,
277
+ momentIds: e.momentIds
278
+ }), this.opts.onSuccess?.(e);
279
+ else if (e.result === "error")
280
+ this.emitAnalytics("subscribe.error", {
281
+ provider: e.provider,
282
+ triggerType: e.triggerType,
283
+ momentIds: e.momentIds
284
+ }), this.opts.onError?.(e);
285
+ else if (e.result === "cancelled")
286
+ return;
287
+ this.opts.onClose?.(e), this.emitAnalytics("embed.close"), this.closeTimer = setTimeout(() => this.cleanup(), 1500);
288
+ }
289
+ handleCancel() {
290
+ this.emitAnalytics("embed.close");
291
+ const e = {
292
+ source: "moment-live-embed",
293
+ type: "moment.embed.result",
294
+ result: "cancelled",
295
+ triggerType: this.activePayload?.triggerType ?? "moment",
296
+ momentIds: this.activePayload?.ids ?? []
297
+ };
298
+ this.opts.onClose?.(e), this.cleanup();
299
+ }
300
+ // ── Private: cleanup ──────────────────────────────────────────────
301
+ cleanup() {
302
+ if (this.closeTimer && (clearTimeout(this.closeTimer), this.closeTimer = null), this.popupBridge && (this.popupBridge.cleanup(), this.popupBridge = null), this.messageHandler && (window.removeEventListener("message", this.messageHandler), this.messageHandler = null), this.overlay) {
303
+ const { _escHandler: e, _focusTrapHandler: t } = this.overlay;
304
+ e && document.removeEventListener("keydown", e);
305
+ const s = this.overlay.firstElementChild;
306
+ s && t && s.removeEventListener("keydown", t), this.overlay.remove(), this.overlay = null;
307
+ }
308
+ this.iframe = null, this.activePayload = null, this.didManageBodyOverflow && (document.body.style.overflow = this.previousBodyOverflow, this.didManageBodyOverflow = !1), this.previousFocusedElement && typeof this.previousFocusedElement.focus == "function" && this.previousFocusedElement.focus(), this.previousFocusedElement = null;
309
+ }
310
+ // ── Private: analytics ────────────────────────────────────────────
311
+ emitAnalytics(e, t) {
312
+ this.opts.onAnalytics?.({
313
+ event: e,
314
+ timestamp: Date.now(),
315
+ ...t
316
+ });
317
+ }
318
+ }
319
+ function f(n) {
320
+ const e = document.createElement("button");
321
+ return e.type = "button", e.classList.add("moment-sync-trigger"), n.className && n.className.split(" ").forEach((t) => {
322
+ t && e.classList.add(t);
323
+ }), Object.assign(e.style, {
324
+ backgroundColor: "#1c1917",
325
+ color: "#ffffff",
326
+ border: "1px solid #292524",
327
+ borderRadius: "4px",
328
+ padding: "8px 16px",
329
+ fontSize: "14px",
330
+ fontWeight: "500",
331
+ cursor: "pointer",
332
+ lineHeight: "1.4"
333
+ }), e.setAttribute("data-moment-team-slug", n.teamSlug), n.externalEventId && e.setAttribute("data-external-event-id", n.externalEventId), n.listSlug && e.setAttribute("data-moment-list-slug", n.listSlug), n.triggerType && e.setAttribute("data-moment-trigger-type", n.triggerType), n.ids?.length && e.setAttribute("data-moment-ids", n.ids.join(",")), n.calendar && e.setAttribute("data-moment-calendar", n.calendar), e.textContent = n.label ?? "Add to Calendar", e;
334
+ }
335
+ if (typeof window < "u") {
336
+ const n = window;
337
+ n.MomentSdk = g, n.createMomentButton = f;
338
+ }
339
+ export {
340
+ g as MomentSdk,
341
+ f as createMomentButton
342
+ };
@@ -0,0 +1,15 @@
1
+ import type { MomentTriggerType, CalendarProvider } from './types';
2
+ /**
3
+ * Programmatically create a trigger button element with the correct
4
+ * data attributes for the MomentSdk to bind to.
5
+ */
6
+ export declare function createMomentButton(options: {
7
+ teamSlug: string;
8
+ externalEventId?: string;
9
+ listSlug?: string;
10
+ triggerType?: MomentTriggerType;
11
+ ids?: string[];
12
+ calendar?: CalendarProvider;
13
+ label?: string;
14
+ className?: string;
15
+ }): HTMLButtonElement;
@@ -0,0 +1,4 @@
1
+ import { MomentSdk } from './moment-sdk';
2
+ import { createMomentButton } from './button';
3
+ export { MomentSdk, createMomentButton };
4
+ export type { MomentSdkInitOptions, MomentSdkOpenPayload, MomentTriggerType, CalendarProvider, MomentResultPayload, MomentOAuthStartPayload, MomentOAuthStatusPayload, MomentAnalyticsEvent, MomentAnalyticsEventName, MomentPostMessage, } from './types';
@@ -0,0 +1,50 @@
1
+ import type { MomentSdkInitOptions, MomentSdkOpenPayload } from './types';
2
+ /**
3
+ * MomentSdk — embeddable calendar sync widget.
4
+ *
5
+ * Opens a modal iframe to a Moment Live embed route for provider selection,
6
+ * then bridges OAuth via popup to avoid iframe OAuth restrictions.
7
+ */
8
+ export declare class MomentSdk {
9
+ private opts;
10
+ private liveBaseUrl;
11
+ private liveOrigin;
12
+ private overlay;
13
+ private iframe;
14
+ private popupBridge;
15
+ private messageHandler;
16
+ private boundClickHandlers;
17
+ private activePayload;
18
+ private previousFocusedElement;
19
+ private previousBodyOverflow;
20
+ private didManageBodyOverflow;
21
+ private closeTimer;
22
+ constructor(options: MomentSdkInitOptions);
23
+ /**
24
+ * Programmatically open the sync modal.
25
+ */
26
+ open(payload: MomentSdkOpenPayload): void;
27
+ /**
28
+ * Close the modal and clean up all resources.
29
+ */
30
+ close(): void;
31
+ /**
32
+ * Re-bind trigger elements (call after dynamic DOM updates).
33
+ */
34
+ rebind(): void;
35
+ /**
36
+ * Destroy the SDK instance, removing all event listeners and DOM.
37
+ */
38
+ destroy(): void;
39
+ private bindTriggers;
40
+ private unbindTriggers;
41
+ private handleTriggerClick;
42
+ private createModal;
43
+ private buildIframeUrl;
44
+ private setupMessageListener;
45
+ private handleOAuthStart;
46
+ private handleResult;
47
+ private handleCancel;
48
+ private cleanup;
49
+ private emitAnalytics;
50
+ }
@@ -0,0 +1,36 @@
1
+ import type { MomentOAuthStartPayload, MomentResultPayload } from './types';
2
+ interface PopupBridgeOptions {
3
+ liveBaseUrl: string;
4
+ liveOrigin: string;
5
+ iframeWindow: Window | null;
6
+ onResult: (payload: MomentResultPayload) => void;
7
+ onPopupBlocked: () => void;
8
+ onPopupClosed: () => void;
9
+ }
10
+ /**
11
+ * Manages the popup lifecycle for OAuth:
12
+ * 1. Opens a popup to the Live OAuth route
13
+ * 2. Polls for popup close
14
+ * 3. Listens for result postMessage from popup
15
+ * 4. Relays status to iframe
16
+ */
17
+ export declare class PopupBridge {
18
+ private popup;
19
+ private pollTimer;
20
+ private messageHandler;
21
+ private resultReceived;
22
+ private opts;
23
+ constructor(opts: PopupBridgeOptions);
24
+ /**
25
+ * Open the OAuth popup in response to a `moment.embed.oauth.start` message.
26
+ */
27
+ open(payload: MomentOAuthStartPayload): void;
28
+ /**
29
+ * Clean up popup, polling, and event listeners.
30
+ */
31
+ cleanup(): void;
32
+ private buildPopupUrl;
33
+ private startPolling;
34
+ private sendStatusToIframe;
35
+ }
36
+ export {};
@@ -0,0 +1,58 @@
1
+ export type MomentTriggerType = 'moment' | 'list' | 'schedule';
2
+ export type CalendarProvider = 'google' | 'outlook';
3
+ export interface MomentSdkInitOptions {
4
+ /** CSS selector for trigger elements. Default: ".moment-sync-trigger" */
5
+ triggerSelector?: string;
6
+ /** Called when calendar sync completes successfully. */
7
+ onSuccess?: (payload: MomentResultPayload) => void;
8
+ /** Called when calendar sync fails. */
9
+ onError?: (payload: MomentResultPayload) => void;
10
+ /** Called when the modal is closed (success, error, or cancel). */
11
+ onClose?: (payload: MomentResultPayload) => void;
12
+ /** Optional analytics callback for tracking embed events. */
13
+ onAnalytics?: (event: MomentAnalyticsEvent) => void;
14
+ }
15
+ export interface MomentSdkOpenPayload {
16
+ teamSlug: string;
17
+ externalEventId?: string;
18
+ listSlug?: string;
19
+ triggerType: MomentTriggerType;
20
+ ids?: string[];
21
+ calendar?: CalendarProvider;
22
+ }
23
+ /** Iframe → Parent: request to start OAuth via popup */
24
+ export interface MomentOAuthStartPayload {
25
+ source: 'moment-live-embed';
26
+ type: 'moment.embed.oauth.start';
27
+ provider: CalendarProvider;
28
+ teamSlug: string;
29
+ ids: string[];
30
+ triggerType: MomentTriggerType;
31
+ returnOrigin: string;
32
+ }
33
+ /** Parent → Iframe: relay OAuth popup status */
34
+ export interface MomentOAuthStatusPayload {
35
+ source: 'moment-sdk';
36
+ type: 'moment.embed.oauth.status';
37
+ status: 'popup-opened' | 'popup-blocked' | 'popup-closed' | 'completed';
38
+ }
39
+ /** Result payload (popup → parent, or iframe → parent for cancel) */
40
+ export interface MomentResultPayload {
41
+ source: 'moment-live-embed';
42
+ type: 'moment.embed.result';
43
+ result: 'success' | 'error' | 'cancelled';
44
+ provider?: CalendarProvider;
45
+ triggerType: MomentTriggerType;
46
+ momentIds: string[];
47
+ message?: string;
48
+ }
49
+ export type MomentAnalyticsEventName = 'embed.init' | 'embed.trigger.click' | 'embed.open' | 'oauth.start' | 'oauth.popup.blocked' | 'oauth.popup.closed' | 'subscribe.success' | 'subscribe.error' | 'embed.close';
50
+ export interface MomentAnalyticsEvent {
51
+ event: MomentAnalyticsEventName;
52
+ provider?: CalendarProvider;
53
+ teamSlug?: string;
54
+ triggerType?: MomentTriggerType;
55
+ momentIds?: string[];
56
+ timestamp: number;
57
+ }
58
+ export type MomentPostMessage = MomentOAuthStartPayload | MomentOAuthStatusPayload | MomentResultPayload;
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@momentco-ai/moment-sdk",
3
+ "version": "0.1.0-dev.1",
4
+ "type": "module",
5
+ "description": "Embeddable calendar sync widget for external team websites",
6
+ "author": "Moment Co. <support@momentco.ai> (https://momentco.ai)",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/Moment-Co/moment-sdk#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Moment-Co/moment-sdk.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/Moment-Co/moment-sdk/issues"
15
+ },
16
+ "keywords": [
17
+ "calendar",
18
+ "sync",
19
+ "embed",
20
+ "widget",
21
+ "google-calendar",
22
+ "outlook",
23
+ "moment"
24
+ ],
25
+ "packageManager": "pnpm@9.15.5",
26
+ "engines": {
27
+ "node": ">=22.12.0"
28
+ },
29
+ "main": "dist/moment-sdk.js",
30
+ "module": "dist/moment-sdk.mjs",
31
+ "unpkg": "dist/moment-sdk.js",
32
+ "jsdelivr": "dist/moment-sdk.js",
33
+ "types": "dist/types/sdk/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/types/sdk/index.d.ts",
37
+ "import": "./dist/moment-sdk.mjs",
38
+ "default": "./dist/moment-sdk.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "registry": "https://registry.npmjs.org"
47
+ },
48
+ "scripts": {
49
+ "build": "vite build && tsc --emitDeclarationOnly --outDir dist/types",
50
+ "build:dev": "vite build --mode development && tsc --emitDeclarationOnly --outDir dist/types",
51
+ "build:prod": "vite build --mode production && tsc --emitDeclarationOnly --outDir dist/types",
52
+ "type-check": "tsc --noEmit",
53
+ "typecheck": "pnpm run type-check",
54
+ "lint": "eslint .",
55
+ "lint:fix": "eslint . --fix",
56
+ "format": "prettier --write .",
57
+ "format:check": "prettier --check .",
58
+ "quality": "pnpm run type-check && pnpm run lint && pnpm run format",
59
+ "prepare": "husky"
60
+ },
61
+ "devDependencies": {
62
+ "@eslint/js": "^9.25.0",
63
+ "eslint": "^9.25.0",
64
+ "husky": "^9.1.7",
65
+ "prettier": "^3.2.5",
66
+ "typescript": "^5.9.2",
67
+ "typescript-eslint": "^8.32.1",
68
+ "vite": "^7.1.5"
69
+ }
70
+ }