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