@jamwidgets/astro 0.1.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,513 @@
1
+ ---
2
+ /**
3
+ * JamWidgets Embed Component
4
+ *
5
+ * Hydrates widget placeholders embedded in post content.
6
+ * Place this component after your post content to activate
7
+ * any embedded polls, reactions, etc.
8
+ *
9
+ * @example
10
+ * <article set:html={post.content} />
11
+ * <Embed siteKey={import.meta.env.JAMWIDGETS_SITE_KEY} />
12
+ */
13
+
14
+ interface Props {
15
+ /** Your site key (required) */
16
+ siteKey: string;
17
+ /** Base URL of your JamWidgets instance (default: 'https://jamwidgets.com') */
18
+ endpoint?: string;
19
+ /** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
20
+ theme?: "light" | "dark" | "auto";
21
+ }
22
+
23
+ const DEFAULT_ENDPOINT = "https://jamwidgets.com";
24
+ const API_PATH = "/api/v1";
25
+
26
+ const {
27
+ siteKey,
28
+ endpoint = DEFAULT_ENDPOINT,
29
+ theme = "light",
30
+ } = Astro.props;
31
+
32
+ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
33
+ ---
34
+
35
+ <jamwidgets-embed
36
+ data-site-key={siteKey}
37
+ data-endpoint={baseUrl}
38
+ data-theme={theme}
39
+ style="display: none;"
40
+ ></jamwidgets-embed>
41
+
42
+ <script>
43
+ interface PollOption {
44
+ id: string;
45
+ text: string;
46
+ }
47
+
48
+ interface PollWithResults {
49
+ id: string;
50
+ question: string;
51
+ options: PollOption[];
52
+ settings: {
53
+ multiSelect: boolean;
54
+ showResults: "always" | "afterVote" | "afterEnd" | "never";
55
+ };
56
+ endsAt?: string;
57
+ isActive: boolean;
58
+ results: Record<string, number>;
59
+ totalVotes: number;
60
+ userVotes?: string[];
61
+ }
62
+
63
+ // Get or create a visitor ID for anonymous users
64
+ const VISITOR_KEY = "jamwidgets_visitor_id";
65
+ function getVisitorId(): string {
66
+ let id = localStorage.getItem(VISITOR_KEY);
67
+ if (!id) {
68
+ id = crypto.randomUUID();
69
+ localStorage.setItem(VISITOR_KEY, id);
70
+ }
71
+ return id;
72
+ }
73
+
74
+ class JamWidgetsEmbed extends HTMLElement {
75
+ connectedCallback() {
76
+ const siteKey = this.dataset.siteKey!;
77
+ const endpoint = this.dataset.endpoint!;
78
+ const theme = this.dataset.theme!;
79
+
80
+ // Find all poll placeholders and hydrate them
81
+ document.querySelectorAll('[data-jamwidgets-embed="poll"]').forEach((el) => {
82
+ const slug = (el as HTMLElement).dataset.slug;
83
+ if (!slug) return;
84
+ this.hydratePoll(el as HTMLElement, { siteKey, endpoint, theme, slug });
85
+ });
86
+ }
87
+
88
+ hydratePoll(
89
+ container: HTMLElement,
90
+ config: { siteKey: string; endpoint: string; theme: string; slug: string },
91
+ ) {
92
+ // Setup container with Poll widget structure
93
+ container.className = `jamwidgets-poll jamwidgets-theme-${config.theme}`;
94
+ container.setAttribute("data-jamwidgets-poll", "");
95
+ container.removeAttribute("data-jamwidgets-embed");
96
+
97
+ // Inject poll HTML structure
98
+ container.innerHTML = `
99
+ <div class="jamwidgets-poll-loading">
100
+ <span class="jamwidgets-poll-spinner"></span>
101
+ </div>
102
+ <div class="jamwidgets-poll-content" style="display: none;">
103
+ <h3 class="jamwidgets-poll-question"></h3>
104
+ <div class="jamwidgets-poll-options"></div>
105
+ <div class="jamwidgets-poll-footer">
106
+ <span class="jamwidgets-poll-total"></span>
107
+ <span class="jamwidgets-poll-status"></span>
108
+ </div>
109
+ <button type="button" class="jamwidgets-poll-submit" style="display: none;">Vote</button>
110
+ </div>
111
+ <div class="jamwidgets-poll-error" style="display: none;"></div>
112
+ `;
113
+
114
+ // Initialize poll
115
+ this.initPoll(container, config);
116
+ }
117
+
118
+ async initPoll(
119
+ container: HTMLElement,
120
+ config: { siteKey: string; endpoint: string; theme: string; slug: string },
121
+ ) {
122
+ const visitorId = getVisitorId();
123
+ const headers = {
124
+ "X-JamWidgets-Key": config.siteKey,
125
+ "X-JamWidgets-Visitor": visitorId,
126
+ };
127
+
128
+ const loadingEl = container.querySelector(".jamwidgets-poll-loading") as HTMLElement;
129
+ const contentEl = container.querySelector(".jamwidgets-poll-content") as HTMLElement;
130
+ const errorEl = container.querySelector(".jamwidgets-poll-error") as HTMLElement;
131
+ const questionEl = container.querySelector(".jamwidgets-poll-question") as HTMLElement;
132
+ const optionsEl = container.querySelector(".jamwidgets-poll-options") as HTMLElement;
133
+ const totalEl = container.querySelector(".jamwidgets-poll-total") as HTMLElement;
134
+ const statusEl = container.querySelector(".jamwidgets-poll-status") as HTMLElement;
135
+ const submitBtn = container.querySelector(".jamwidgets-poll-submit") as HTMLButtonElement;
136
+
137
+ let poll: PollWithResults | null = null;
138
+ let selectedOptions: Set<string> = new Set();
139
+
140
+ async function loadPoll() {
141
+ try {
142
+ const response = await fetch(`${config.endpoint}/polls/${config.slug}`, { headers });
143
+ if (!response.ok) throw new Error("Failed to load poll");
144
+ const data = await response.json();
145
+ poll = data.poll_with_results;
146
+ renderPoll();
147
+ } catch (e) {
148
+ console.error("Failed to load poll:", e);
149
+ loadingEl.style.display = "none";
150
+ errorEl.style.display = "block";
151
+ errorEl.textContent = "Failed to load poll";
152
+ }
153
+ }
154
+
155
+ function renderPoll() {
156
+ if (!poll) return;
157
+
158
+ loadingEl.style.display = "none";
159
+ contentEl.style.display = "block";
160
+
161
+ questionEl.textContent = poll.question;
162
+
163
+ const hasVoted = poll.userVotes && poll.userVotes.length > 0;
164
+ const isEnded = poll.endsAt ? new Date(poll.endsAt) < new Date() : false;
165
+ const canVote = poll.isActive && !isEnded && !hasVoted;
166
+ const showResults = Object.keys(poll.results).length > 0;
167
+
168
+ // Pre-select user's votes if any
169
+ if (poll.userVotes) {
170
+ selectedOptions = new Set(poll.userVotes);
171
+ }
172
+
173
+ optionsEl.innerHTML = "";
174
+ poll.options.forEach((option) => {
175
+ const optionEl = document.createElement("div");
176
+ optionEl.className = "jamwidgets-poll-option";
177
+
178
+ const votes = poll!.results[option.id] || 0;
179
+ const percent = poll!.totalVotes > 0 ? Math.round((votes / poll!.totalVotes) * 100) : 0;
180
+ const isSelected = selectedOptions.has(option.id);
181
+
182
+ if (canVote) {
183
+ // Interactive option
184
+ const inputType = poll!.settings.multiSelect ? "checkbox" : "radio";
185
+ optionEl.innerHTML = `
186
+ <label class="jamwidgets-poll-label ${isSelected ? "jamwidgets-selected" : ""}">
187
+ <input type="${inputType}" name="poll-${poll!.id}" value="${option.id}" ${isSelected ? "checked" : ""} />
188
+ <span class="jamwidgets-poll-text">${option.text}</span>
189
+ </label>
190
+ `;
191
+ const input = optionEl.querySelector("input")!;
192
+ input.addEventListener("change", () => {
193
+ if (poll!.settings.multiSelect) {
194
+ if (input.checked) {
195
+ selectedOptions.add(option.id);
196
+ } else {
197
+ selectedOptions.delete(option.id);
198
+ }
199
+ } else {
200
+ selectedOptions = new Set([option.id]);
201
+ }
202
+ updateSubmitButton();
203
+ // Update visual selection
204
+ optionsEl.querySelectorAll(".jamwidgets-poll-label").forEach((label) => {
205
+ const labelInput = label.querySelector("input") as HTMLInputElement;
206
+ label.classList.toggle("jamwidgets-selected", labelInput.checked);
207
+ });
208
+ });
209
+ } else if (showResults) {
210
+ // Show results
211
+ optionEl.innerHTML = `
212
+ <div class="jamwidgets-poll-result ${isSelected ? "jamwidgets-voted" : ""}">
213
+ <div class="jamwidgets-poll-bar" style="width: ${percent}%"></div>
214
+ <span class="jamwidgets-poll-text">${option.text}</span>
215
+ <span class="jamwidgets-poll-percent">${percent}%</span>
216
+ </div>
217
+ `;
218
+ } else {
219
+ // Show without results (voted but results hidden)
220
+ optionEl.innerHTML = `
221
+ <div class="jamwidgets-poll-result jamwidgets-no-results ${isSelected ? "jamwidgets-voted" : ""}">
222
+ <span class="jamwidgets-poll-text">${option.text}</span>
223
+ </div>
224
+ `;
225
+ }
226
+ optionsEl.appendChild(optionEl);
227
+ });
228
+
229
+ // Footer
230
+ if (showResults) {
231
+ totalEl.textContent = `${poll.totalVotes} vote${poll.totalVotes !== 1 ? "s" : ""}`;
232
+ } else {
233
+ totalEl.textContent = "";
234
+ }
235
+
236
+ if (isEnded) {
237
+ statusEl.textContent = "Ended";
238
+ } else if (!poll.isActive) {
239
+ statusEl.textContent = "Closed";
240
+ } else if (hasVoted) {
241
+ statusEl.textContent = "Voted";
242
+ } else {
243
+ statusEl.textContent = "";
244
+ }
245
+
246
+ // Submit button
247
+ if (canVote) {
248
+ submitBtn.style.display = "";
249
+ updateSubmitButton();
250
+ } else {
251
+ submitBtn.style.display = "none";
252
+ }
253
+ }
254
+
255
+ function updateSubmitButton() {
256
+ submitBtn.disabled = selectedOptions.size === 0;
257
+ }
258
+
259
+ submitBtn.addEventListener("click", async () => {
260
+ if (selectedOptions.size === 0) return;
261
+
262
+ submitBtn.disabled = true;
263
+ submitBtn.textContent = "Voting...";
264
+
265
+ try {
266
+ const response = await fetch(`${config.endpoint}/polls/${config.slug}/vote`, {
267
+ method: "POST",
268
+ headers: {
269
+ ...headers,
270
+ "Content-Type": "application/json",
271
+ },
272
+ body: JSON.stringify({ selectedOptions: Array.from(selectedOptions) }),
273
+ });
274
+
275
+ if (!response.ok) {
276
+ const error = await response.json();
277
+ throw new Error(error.message || "Failed to vote");
278
+ }
279
+
280
+ const data = await response.json();
281
+ const result = data.vote_response;
282
+
283
+ // Update poll with new results
284
+ if (poll) {
285
+ poll.results = result.results;
286
+ poll.totalVotes = result.totalVotes;
287
+ poll.userVotes = Array.from(selectedOptions);
288
+ }
289
+
290
+ container.dispatchEvent(
291
+ new CustomEvent("jamwidgets:poll-voted", {
292
+ detail: { pollSlug: config.slug, selectedOptions: Array.from(selectedOptions), results: result },
293
+ }),
294
+ );
295
+
296
+ renderPoll();
297
+ } catch (e) {
298
+ console.error("Failed to vote:", e);
299
+ submitBtn.disabled = false;
300
+ submitBtn.textContent = "Vote";
301
+ errorEl.style.display = "block";
302
+ errorEl.textContent = e instanceof Error ? e.message : "Failed to vote";
303
+ setTimeout(() => {
304
+ errorEl.style.display = "none";
305
+ }, 3000);
306
+ }
307
+ });
308
+
309
+ loadPoll();
310
+ }
311
+ }
312
+
313
+ customElements.define("jamwidgets-embed", JamWidgetsEmbed);
314
+ </script>
315
+
316
+ <style is:global>
317
+ @layer jamwidgets {
318
+ .jamwidgets-poll {
319
+ --jamwidgets-border-color: #e5e7eb;
320
+ --jamwidgets-bg-color: white;
321
+ --jamwidgets-text-color: inherit;
322
+ --jamwidgets-text-muted: #6b7280;
323
+ --jamwidgets-hover-bg: #f3f4f6;
324
+ --jamwidgets-selected-bg: #dbeafe;
325
+ --jamwidgets-selected-border: #3b82f6;
326
+ --jamwidgets-bar-bg: #dbeafe;
327
+ --jamwidgets-voted-bar-bg: #3b82f6;
328
+ --jamwidgets-button-bg: #3b82f6;
329
+ --jamwidgets-button-text: white;
330
+ --jamwidgets-button-hover: #2563eb;
331
+
332
+ font-family: inherit;
333
+ }
334
+
335
+ /* Dark theme */
336
+ .jamwidgets-poll.jamwidgets-theme-dark {
337
+ --jamwidgets-border-color: #374151;
338
+ --jamwidgets-bg-color: transparent;
339
+ --jamwidgets-text-color: #f3f4f6;
340
+ --jamwidgets-text-muted: #9ca3af;
341
+ --jamwidgets-hover-bg: #374151;
342
+ --jamwidgets-selected-bg: rgba(59, 130, 246, 0.2);
343
+ --jamwidgets-selected-border: #3b82f6;
344
+ --jamwidgets-bar-bg: rgba(59, 130, 246, 0.2);
345
+ --jamwidgets-voted-bar-bg: #3b82f6;
346
+ }
347
+
348
+ /* Auto theme */
349
+ @media (prefers-color-scheme: dark) {
350
+ .jamwidgets-poll.jamwidgets-theme-auto {
351
+ --jamwidgets-border-color: #374151;
352
+ --jamwidgets-bg-color: transparent;
353
+ --jamwidgets-text-color: #f3f4f6;
354
+ --jamwidgets-text-muted: #9ca3af;
355
+ --jamwidgets-hover-bg: #374151;
356
+ --jamwidgets-selected-bg: rgba(59, 130, 246, 0.2);
357
+ --jamwidgets-selected-border: #3b82f6;
358
+ --jamwidgets-bar-bg: rgba(59, 130, 246, 0.2);
359
+ --jamwidgets-voted-bar-bg: #3b82f6;
360
+ }
361
+ }
362
+
363
+ .jamwidgets-poll-loading {
364
+ display: flex;
365
+ justify-content: center;
366
+ padding: 2rem;
367
+ }
368
+
369
+ .jamwidgets-poll-spinner {
370
+ width: 1.5rem;
371
+ height: 1.5rem;
372
+ border: 2px solid var(--jamwidgets-border-color);
373
+ border-top-color: var(--jamwidgets-button-bg);
374
+ border-radius: 50%;
375
+ animation: jamwidgets-spin 0.8s linear infinite;
376
+ }
377
+
378
+ @keyframes jamwidgets-spin {
379
+ to { transform: rotate(360deg); }
380
+ }
381
+
382
+ .jamwidgets-poll-question {
383
+ font-weight: 600;
384
+ font-size: 1.125rem;
385
+ margin: 0 0 1rem;
386
+ color: var(--jamwidgets-text-color);
387
+ }
388
+
389
+ .jamwidgets-poll-options {
390
+ display: flex;
391
+ flex-direction: column;
392
+ gap: 0.5rem;
393
+ }
394
+
395
+ .jamwidgets-poll-label {
396
+ display: flex;
397
+ align-items: center;
398
+ gap: 0.75rem;
399
+ padding: 0.75rem 1rem;
400
+ border: 1px solid var(--jamwidgets-border-color);
401
+ border-radius: 0.5rem;
402
+ cursor: pointer;
403
+ transition: all 0.15s;
404
+ }
405
+
406
+ .jamwidgets-poll-label:hover {
407
+ background: var(--jamwidgets-hover-bg);
408
+ }
409
+
410
+ .jamwidgets-poll-label.jamwidgets-selected {
411
+ background: var(--jamwidgets-selected-bg);
412
+ border-color: var(--jamwidgets-selected-border);
413
+ }
414
+
415
+ .jamwidgets-poll-label input {
416
+ margin: 0;
417
+ }
418
+
419
+ .jamwidgets-poll-result {
420
+ position: relative;
421
+ padding: 0.75rem 1rem;
422
+ border: 1px solid var(--jamwidgets-border-color);
423
+ border-radius: 0.5rem;
424
+ overflow: hidden;
425
+ display: flex;
426
+ justify-content: space-between;
427
+ align-items: center;
428
+ }
429
+
430
+ .jamwidgets-poll-result.jamwidgets-voted {
431
+ border-color: var(--jamwidgets-selected-border);
432
+ }
433
+
434
+ .jamwidgets-poll-bar {
435
+ position: absolute;
436
+ top: 0;
437
+ left: 0;
438
+ bottom: 0;
439
+ background: var(--jamwidgets-bar-bg);
440
+ transition: width 0.3s ease;
441
+ }
442
+
443
+ .jamwidgets-poll-result.jamwidgets-voted .jamwidgets-poll-bar {
444
+ background: var(--jamwidgets-voted-bar-bg);
445
+ opacity: 0.2;
446
+ }
447
+
448
+ .jamwidgets-poll-text {
449
+ position: relative;
450
+ z-index: 1;
451
+ }
452
+
453
+ .jamwidgets-poll-percent {
454
+ position: relative;
455
+ z-index: 1;
456
+ font-weight: 500;
457
+ font-size: 0.875rem;
458
+ color: var(--jamwidgets-text-muted);
459
+ }
460
+
461
+ .jamwidgets-poll-footer {
462
+ display: flex;
463
+ justify-content: space-between;
464
+ align-items: center;
465
+ margin-top: 0.75rem;
466
+ font-size: 0.875rem;
467
+ color: var(--jamwidgets-text-muted);
468
+ }
469
+
470
+ .jamwidgets-poll-submit {
471
+ margin-top: 1rem;
472
+ width: 100%;
473
+ padding: 0.625rem 1rem;
474
+ background: var(--jamwidgets-button-bg);
475
+ color: var(--jamwidgets-button-text);
476
+ border: none;
477
+ border-radius: 0.5rem;
478
+ font-weight: 500;
479
+ cursor: pointer;
480
+ transition: background 0.15s;
481
+ }
482
+
483
+ .jamwidgets-poll-submit:hover:not(:disabled) {
484
+ background: var(--jamwidgets-button-hover);
485
+ }
486
+
487
+ .jamwidgets-poll-submit:disabled {
488
+ opacity: 0.5;
489
+ cursor: not-allowed;
490
+ }
491
+
492
+ .jamwidgets-poll-error {
493
+ padding: 0.75rem;
494
+ background: #fef2f2;
495
+ color: #991b1b;
496
+ border-radius: 0.5rem;
497
+ font-size: 0.875rem;
498
+ }
499
+
500
+ .jamwidgets-theme-dark .jamwidgets-poll-error,
501
+ .jamwidgets-theme-auto .jamwidgets-poll-error {
502
+ background: rgba(239, 68, 68, 0.15);
503
+ color: #fca5a5;
504
+ }
505
+
506
+ @media (prefers-color-scheme: dark) {
507
+ .jamwidgets-theme-auto .jamwidgets-poll-error {
508
+ background: rgba(239, 68, 68, 0.15);
509
+ color: #fca5a5;
510
+ }
511
+ }
512
+ }
513
+ </style>