@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,327 @@
1
+ ---
2
+ /**
3
+ * JamWidgets Feedback Component
4
+ *
5
+ * Displays a feedback form for collecting user suggestions.
6
+ * Supports different feedback types (bug, feature, improvement, other).
7
+ *
8
+ * @example
9
+ * <Feedback siteKey={import.meta.env.JAMWIDGETS_SITE_KEY} />
10
+ */
11
+
12
+ interface Props {
13
+ /** Your site key (required) */
14
+ siteKey: string;
15
+ /** Base URL of your JamWidgets instance (default: 'https://jamwidgets.com') */
16
+ endpoint?: string;
17
+ /** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
18
+ theme?: "light" | "dark" | "auto";
19
+ /** Placeholder text for the feedback input (default: 'Share your feedback...') */
20
+ placeholder?: string;
21
+ /** Show feedback type selector (default: true) */
22
+ showTypeSelector?: boolean;
23
+ /** CSS class to add to the container */
24
+ class?: string;
25
+ }
26
+
27
+ const DEFAULT_ENDPOINT = "https://jamwidgets.com";
28
+ const API_PATH = "/api/v1";
29
+
30
+ const {
31
+ siteKey,
32
+ endpoint = DEFAULT_ENDPOINT,
33
+ theme = "light",
34
+ placeholder = "Share your feedback...",
35
+ showTypeSelector = true,
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-feedback", `jamwidgets-theme-${theme}`, className]}
45
+ data-jamwidgets-feedback
46
+ data-endpoint={baseUrl}
47
+ data-site-key={siteKey}
48
+ >
49
+ <form class="jamwidgets-feedback-form">
50
+ {showTypeSelector && (
51
+ <div class="jamwidgets-feedback-type-selector">
52
+ <label class="jamwidgets-feedback-type">
53
+ <input type="radio" name="feedbackType" value="feature" checked />
54
+ <span>Feature</span>
55
+ </label>
56
+ <label class="jamwidgets-feedback-type">
57
+ <input type="radio" name="feedbackType" value="bug" />
58
+ <span>Bug</span>
59
+ </label>
60
+ <label class="jamwidgets-feedback-type">
61
+ <input type="radio" name="feedbackType" value="improvement" />
62
+ <span>Improvement</span>
63
+ </label>
64
+ <label class="jamwidgets-feedback-type">
65
+ <input type="radio" name="feedbackType" value="other" />
66
+ <span>Other</span>
67
+ </label>
68
+ </div>
69
+ )}
70
+ <textarea
71
+ name="content"
72
+ class="jamwidgets-feedback-textarea"
73
+ placeholder={placeholder}
74
+ rows="3"
75
+ required
76
+ ></textarea>
77
+ <div class="jamwidgets-feedback-footer">
78
+ <span class="jamwidgets-feedback-message"></span>
79
+ <button type="submit" class="jamwidgets-feedback-submit">
80
+ Send Feedback
81
+ </button>
82
+ </div>
83
+ </form>
84
+ </div>
85
+
86
+ <script>
87
+ // Get or create a visitor ID for anonymous users
88
+ const VISITOR_KEY = "jamwidgets_visitor_id";
89
+ function getVisitorId(): string {
90
+ let id = localStorage.getItem(VISITOR_KEY);
91
+ if (!id) {
92
+ id = crypto.randomUUID();
93
+ localStorage.setItem(VISITOR_KEY, id);
94
+ }
95
+ return id;
96
+ }
97
+
98
+ document.querySelectorAll("[data-jamwidgets-feedback]").forEach((container) => {
99
+ const endpoint = (container as HTMLElement).dataset.endpoint;
100
+ const siteKey = (container as HTMLElement).dataset.siteKey;
101
+
102
+ if (!endpoint || !siteKey) return;
103
+
104
+ const visitorId = getVisitorId();
105
+ const headers = {
106
+ "X-JamWidgets-Key": siteKey,
107
+ "X-JamWidgets-Visitor": visitorId,
108
+ "Content-Type": "application/json",
109
+ };
110
+
111
+ const form = container.querySelector(".jamwidgets-feedback-form") as HTMLFormElement;
112
+ const textarea = container.querySelector(".jamwidgets-feedback-textarea") as HTMLTextAreaElement;
113
+ const submitBtn = container.querySelector(".jamwidgets-feedback-submit") as HTMLButtonElement;
114
+ const messageEl = container.querySelector(".jamwidgets-feedback-message") as HTMLElement;
115
+
116
+ form.addEventListener("submit", async (e) => {
117
+ e.preventDefault();
118
+
119
+ const content = textarea.value.trim();
120
+ if (!content) return;
121
+
122
+ const typeInput = form.querySelector('input[name="feedbackType"]:checked') as HTMLInputElement | null;
123
+ const feedbackType = typeInput?.value || "other";
124
+
125
+ submitBtn.disabled = true;
126
+ submitBtn.textContent = "Sending...";
127
+ messageEl.textContent = "";
128
+ messageEl.className = "jamwidgets-feedback-message";
129
+
130
+ try {
131
+ const response = await fetch(`${endpoint}/feedback`, {
132
+ method: "POST",
133
+ headers,
134
+ body: JSON.stringify({ content, feedbackType }),
135
+ });
136
+
137
+ if (!response.ok) {
138
+ const error = await response.json();
139
+ throw new Error(error.message || "Failed to submit feedback");
140
+ }
141
+
142
+ textarea.value = "";
143
+ messageEl.textContent = "Thank you for your feedback!";
144
+ messageEl.className = "jamwidgets-feedback-message jamwidgets-success";
145
+
146
+ container.dispatchEvent(
147
+ new CustomEvent("jamwidgets:feedback-submitted", {
148
+ detail: { content, feedbackType },
149
+ }),
150
+ );
151
+
152
+ setTimeout(() => {
153
+ messageEl.textContent = "";
154
+ messageEl.className = "jamwidgets-feedback-message";
155
+ }, 3000);
156
+ } catch (e) {
157
+ console.error("Failed to submit feedback:", e);
158
+ messageEl.textContent = e instanceof Error ? e.message : "Failed to submit feedback";
159
+ messageEl.className = "jamwidgets-feedback-message jamwidgets-error";
160
+ } finally {
161
+ submitBtn.disabled = false;
162
+ submitBtn.textContent = "Send Feedback";
163
+ }
164
+ });
165
+ });
166
+ </script>
167
+
168
+ <style is:global>
169
+ .jamwidgets-feedback {
170
+ --jamwidgets-border-color: #9ca3af;
171
+ --jamwidgets-bg-color: white;
172
+ --jamwidgets-text-color: inherit;
173
+ --jamwidgets-text-muted: #6b7280;
174
+ --jamwidgets-hover-bg: #f3f4f6;
175
+ --jamwidgets-selected-bg: #dbeafe;
176
+ --jamwidgets-selected-border: #3b82f6;
177
+ --jamwidgets-button-bg: #3b82f6;
178
+ --jamwidgets-button-text: white;
179
+ --jamwidgets-button-hover: #2563eb;
180
+ --jamwidgets-success-color: #16a34a;
181
+ --jamwidgets-error-color: #dc2626;
182
+ font-family: inherit;
183
+ }
184
+
185
+ .jamwidgets-feedback.jamwidgets-theme-dark {
186
+ --jamwidgets-border-color: #9ca3af;
187
+ --jamwidgets-bg-color: #374151;
188
+ --jamwidgets-text-color: #f3f4f6;
189
+ --jamwidgets-text-muted: #9ca3af;
190
+ --jamwidgets-hover-bg: #4b5563;
191
+ --jamwidgets-selected-bg: rgba(59, 130, 246, 0.3);
192
+ --jamwidgets-selected-border: #60a5fa;
193
+ --jamwidgets-success-color: #22c55e;
194
+ --jamwidgets-error-color: #ef4444;
195
+ }
196
+
197
+ @media (prefers-color-scheme: dark) {
198
+ .jamwidgets-feedback.jamwidgets-theme-auto {
199
+ --jamwidgets-border-color: #9ca3af;
200
+ --jamwidgets-bg-color: #374151;
201
+ --jamwidgets-text-color: #f3f4f6;
202
+ --jamwidgets-text-muted: #9ca3af;
203
+ --jamwidgets-hover-bg: #4b5563;
204
+ --jamwidgets-selected-bg: rgba(59, 130, 246, 0.3);
205
+ --jamwidgets-selected-border: #60a5fa;
206
+ --jamwidgets-success-color: #22c55e;
207
+ --jamwidgets-error-color: #ef4444;
208
+ }
209
+ }
210
+
211
+ .jamwidgets-feedback-form {
212
+ display: flex;
213
+ flex-direction: column;
214
+ gap: 0.75rem;
215
+ }
216
+
217
+ .jamwidgets-feedback-type-selector {
218
+ display: flex;
219
+ flex-wrap: wrap;
220
+ gap: 0.5rem;
221
+ }
222
+
223
+ .jamwidgets-feedback-type {
224
+ position: relative;
225
+ display: inline-flex;
226
+ align-items: center;
227
+ cursor: pointer;
228
+ }
229
+
230
+ .jamwidgets-feedback-type input {
231
+ position: absolute;
232
+ width: 1px;
233
+ height: 1px;
234
+ padding: 0;
235
+ margin: -1px;
236
+ overflow: hidden;
237
+ clip: rect(0, 0, 0, 0);
238
+ white-space: nowrap;
239
+ border: 0;
240
+ }
241
+
242
+ .jamwidgets-feedback-type span {
243
+ display: inline-block;
244
+ padding: 0.375rem 0.75rem;
245
+ font-size: 0.875rem;
246
+ border: 1px solid var(--jamwidgets-border-color);
247
+ border-radius: 9999px;
248
+ transition: all 0.15s;
249
+ color: var(--jamwidgets-text-color);
250
+ background: transparent;
251
+ }
252
+
253
+ .jamwidgets-feedback-type:hover span {
254
+ background: var(--jamwidgets-hover-bg);
255
+ }
256
+
257
+ .jamwidgets-feedback-type input:checked + span {
258
+ background: var(--jamwidgets-selected-bg);
259
+ border-color: var(--jamwidgets-selected-border);
260
+ color: var(--jamwidgets-selected-border);
261
+ }
262
+
263
+ .jamwidgets-feedback .jamwidgets-feedback-textarea {
264
+ width: 100%;
265
+ padding: 0.75rem;
266
+ font-size: 0.875rem;
267
+ border: 1px solid var(--jamwidgets-border-color);
268
+ border-radius: 0.5rem;
269
+ background: var(--jamwidgets-bg-color);
270
+ color: var(--jamwidgets-text-color);
271
+ resize: vertical;
272
+ min-height: 80px;
273
+ font-family: inherit;
274
+ }
275
+
276
+ .jamwidgets-feedback .jamwidgets-feedback-textarea:focus {
277
+ outline: none;
278
+ border-color: var(--jamwidgets-selected-border);
279
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
280
+ }
281
+
282
+ .jamwidgets-feedback .jamwidgets-feedback-textarea::placeholder {
283
+ color: var(--jamwidgets-text-muted);
284
+ }
285
+
286
+ .jamwidgets-feedback-footer {
287
+ display: flex;
288
+ justify-content: space-between;
289
+ align-items: center;
290
+ gap: 1rem;
291
+ }
292
+
293
+ .jamwidgets-feedback-message {
294
+ font-size: 0.875rem;
295
+ color: var(--jamwidgets-text-muted);
296
+ }
297
+
298
+ .jamwidgets-feedback-message.jamwidgets-success {
299
+ color: var(--jamwidgets-success-color);
300
+ }
301
+
302
+ .jamwidgets-feedback-message.jamwidgets-error {
303
+ color: var(--jamwidgets-error-color);
304
+ }
305
+
306
+ .jamwidgets-feedback .jamwidgets-feedback-submit {
307
+ padding: 0.5rem 1rem;
308
+ background: var(--jamwidgets-button-bg);
309
+ color: var(--jamwidgets-button-text);
310
+ border: none;
311
+ border-radius: 0.5rem;
312
+ font-size: 0.875rem;
313
+ font-weight: 500;
314
+ cursor: pointer;
315
+ transition: background 0.15s;
316
+ white-space: nowrap;
317
+ }
318
+
319
+ .jamwidgets-feedback .jamwidgets-feedback-submit:hover:not(:disabled) {
320
+ background: var(--jamwidgets-button-hover);
321
+ }
322
+
323
+ .jamwidgets-feedback .jamwidgets-feedback-submit:disabled {
324
+ opacity: 0.5;
325
+ cursor: not-allowed;
326
+ }
327
+ </style>
package/src/Form.astro ADDED
@@ -0,0 +1,237 @@
1
+ ---
2
+ /**
3
+ * JamWidgets Form Component
4
+ *
5
+ * A wrapper component that handles form submission to JamWidgets.
6
+ * You provide your own form fields via the default slot.
7
+ *
8
+ * @example
9
+ * <Form siteKey={import.meta.env.JAMWIDGETS_SITE_KEY} formSlug="contact">
10
+ * <input name="name" placeholder="Name" required />
11
+ * <input name="email" type="email" placeholder="Email" required />
12
+ * <textarea name="message" placeholder="Message" required></textarea>
13
+ * <button type="submit">Send</button>
14
+ * </Form>
15
+ */
16
+
17
+ interface Props {
18
+ /** Your site key (required) */
19
+ siteKey: string;
20
+ /** The form slug as configured in JamWidgets (required) */
21
+ formSlug: string;
22
+ /** Base URL of your JamWidgets instance (default: 'https://jamwidgets.com') */
23
+ endpoint?: string;
24
+ /** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
25
+ theme?: "light" | "dark" | "auto";
26
+ /** CSS class to add to the form */
27
+ class?: string;
28
+ }
29
+
30
+ const DEFAULT_ENDPOINT = "https://jamwidgets.com";
31
+ const API_PATH = "/api/v1";
32
+
33
+ const {
34
+ siteKey,
35
+ formSlug,
36
+ endpoint = DEFAULT_ENDPOINT,
37
+ theme = "light",
38
+ class: className = "",
39
+ } = Astro.props;
40
+
41
+ // Build the API URL
42
+ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
43
+ ---
44
+
45
+ <form
46
+ class:list={["jamwidgets-form", `jamwidgets-theme-${theme}`, className]}
47
+ data-jamwidgets-form
48
+ data-endpoint={baseUrl}
49
+ data-site-key={siteKey}
50
+ data-form-slug={formSlug}
51
+ >
52
+ <slot />
53
+
54
+ <!-- Honeypot field for spam protection (hidden from users, catches bots) -->
55
+ <div style="position: absolute; left: -9999px;" aria-hidden="true">
56
+ <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
57
+ </div>
58
+
59
+ <!-- Success/Error message container (hidden by default) -->
60
+ <div class="jamwidgets-form-message" style="display: none;" aria-live="polite"></div>
61
+ </form>
62
+
63
+ <script>
64
+ interface FormSubmitResponse {
65
+ success: boolean;
66
+ message: string;
67
+ }
68
+
69
+ // Get or create a visitor ID for anonymous users
70
+ const VISITOR_KEY = "jamwidgets_visitor_id";
71
+ function getVisitorId(): string {
72
+ let id = localStorage.getItem(VISITOR_KEY);
73
+ if (!id) {
74
+ id = crypto.randomUUID();
75
+ localStorage.setItem(VISITOR_KEY, id);
76
+ }
77
+ return id;
78
+ }
79
+
80
+ // Store form load timestamps for spam detection
81
+ const formTimestamps = new WeakMap<Element, number>();
82
+
83
+ document.querySelectorAll("[data-jamwidgets-form]").forEach((form) => {
84
+ // Record when form was loaded (for time-based spam detection)
85
+ formTimestamps.set(form, Math.floor(Date.now() / 1000));
86
+
87
+ form.addEventListener("submit", async (e) => {
88
+ e.preventDefault();
89
+
90
+ const formEl = e.target as HTMLFormElement;
91
+ const endpoint = formEl.dataset.endpoint;
92
+ const siteKey = formEl.dataset.siteKey;
93
+ const formSlug = formEl.dataset.formSlug;
94
+ const messageEl = formEl.querySelector(".jamwidgets-form-message") as HTMLElement | null;
95
+ const submitBtn = formEl.querySelector('[type="submit"]') as HTMLButtonElement | null;
96
+
97
+ if (!endpoint || !siteKey || !formSlug) {
98
+ const missing = [];
99
+ if (!endpoint) missing.push("endpoint");
100
+ if (!formSlug) missing.push("formSlug");
101
+ if (!siteKey) {
102
+ missing.push("siteKey (check JAMWIDGETS_SITE_KEY in your .env)");
103
+ }
104
+ console.error(`JamWidgets form missing required props: ${missing.join(", ")}`);
105
+ return;
106
+ }
107
+
108
+ // Collect form data
109
+ const formData = new FormData(formEl);
110
+ const data: Record<string, unknown> = {};
111
+ formData.forEach((value, key) => {
112
+ data[key] = value;
113
+ });
114
+
115
+ // Add timestamp for time-based spam detection
116
+ const loadTimestamp = formTimestamps.get(formEl);
117
+ if (loadTimestamp) {
118
+ data._jamwidgets_ts = loadTimestamp;
119
+ }
120
+
121
+ // Dispatch loading event
122
+ formEl.dispatchEvent(new CustomEvent("jamwidgets:loading"));
123
+
124
+ // Disable submit button
125
+ if (submitBtn) {
126
+ submitBtn.disabled = true;
127
+ }
128
+
129
+ try {
130
+ const response = await fetch(`${endpoint}/forms/${formSlug}/submit`, {
131
+ method: "POST",
132
+ headers: {
133
+ "Content-Type": "application/json",
134
+ "X-JamWidgets-Key": siteKey,
135
+ "X-JamWidgets-Visitor": getVisitorId(),
136
+ },
137
+ body: JSON.stringify(data),
138
+ });
139
+
140
+ if (!response.ok) {
141
+ const errorText = await response.text();
142
+ throw new Error(errorText || "Submission failed");
143
+ }
144
+
145
+ const result: FormSubmitResponse = await response.json();
146
+
147
+ // Show success message
148
+ if (messageEl) {
149
+ messageEl.textContent = result.message;
150
+ messageEl.style.display = "block";
151
+ messageEl.className = "jamwidgets-form-message jamwidgets-form-success";
152
+ }
153
+
154
+ // Dispatch success event with response data
155
+ formEl.dispatchEvent(
156
+ new CustomEvent("jamwidgets:success", {
157
+ detail: result,
158
+ }),
159
+ );
160
+
161
+ // Reset form on success
162
+ formEl.reset();
163
+ } catch (error) {
164
+ // Show error message
165
+ if (messageEl) {
166
+ messageEl.textContent = error instanceof Error ? error.message : "Something went wrong";
167
+ messageEl.style.display = "block";
168
+ messageEl.className = "jamwidgets-form-message jamwidgets-form-error";
169
+ }
170
+
171
+ formEl.dispatchEvent(
172
+ new CustomEvent("jamwidgets:error", {
173
+ detail: error,
174
+ }),
175
+ );
176
+ } finally {
177
+ // Re-enable submit button
178
+ if (submitBtn) {
179
+ submitBtn.disabled = false;
180
+ }
181
+ }
182
+ });
183
+ });
184
+ </script>
185
+
186
+ <style is:global>
187
+ @layer jamwidgets {
188
+ .jamwidgets-form {
189
+ --jamwidgets-success-bg: #dcfce7;
190
+ --jamwidgets-success-text: #166534;
191
+ --jamwidgets-success-border: #bbf7d0;
192
+ --jamwidgets-error-bg: #fef2f2;
193
+ --jamwidgets-error-text: #991b1b;
194
+ --jamwidgets-error-border: #fecaca;
195
+ }
196
+
197
+ /* Dark theme */
198
+ .jamwidgets-form.jamwidgets-theme-dark {
199
+ --jamwidgets-success-bg: rgba(34, 197, 94, 0.15);
200
+ --jamwidgets-success-text: #86efac;
201
+ --jamwidgets-success-border: rgba(34, 197, 94, 0.3);
202
+ --jamwidgets-error-bg: rgba(239, 68, 68, 0.15);
203
+ --jamwidgets-error-text: #fca5a5;
204
+ --jamwidgets-error-border: rgba(239, 68, 68, 0.3);
205
+ }
206
+
207
+ /* Auto theme - follows prefers-color-scheme */
208
+ @media (prefers-color-scheme: dark) {
209
+ .jamwidgets-form.jamwidgets-theme-auto {
210
+ --jamwidgets-success-bg: rgba(34, 197, 94, 0.15);
211
+ --jamwidgets-success-text: #86efac;
212
+ --jamwidgets-success-border: rgba(34, 197, 94, 0.3);
213
+ --jamwidgets-error-bg: rgba(239, 68, 68, 0.15);
214
+ --jamwidgets-error-text: #fca5a5;
215
+ --jamwidgets-error-border: rgba(239, 68, 68, 0.3);
216
+ }
217
+ }
218
+
219
+ .jamwidgets-form-message {
220
+ margin-top: 1rem;
221
+ padding: 0.75rem 1rem;
222
+ border-radius: 0.375rem;
223
+ }
224
+
225
+ .jamwidgets-form-success {
226
+ background-color: var(--jamwidgets-success-bg);
227
+ color: var(--jamwidgets-success-text);
228
+ border: 1px solid var(--jamwidgets-success-border);
229
+ }
230
+
231
+ .jamwidgets-form-error {
232
+ background-color: var(--jamwidgets-error-bg);
233
+ color: var(--jamwidgets-error-text);
234
+ border: 1px solid var(--jamwidgets-error-border);
235
+ }
236
+ }
237
+ </style>