@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,154 @@
1
+ ---
2
+ /**
3
+ * JamWidgets Headless Subscribe Form
4
+ *
5
+ * A headless subscription form that handles API/state but lets you provide your own UI.
6
+ * Use the default slot to provide your own email input and submit button.
7
+ *
8
+ * Required: An input with name="email" and a submit button.
9
+ *
10
+ * @example Basic usage
11
+ * <SubscribeForm siteKey={import.meta.env.JAMWIDGETS_SITE_KEY}>
12
+ * <input type="email" name="email" placeholder="you@example.com" required />
13
+ * <button type="submit">Subscribe</button>
14
+ * </SubscribeForm>
15
+ *
16
+ * @example With custom message handling
17
+ * <SubscribeForm siteKey={import.meta.env.JAMWIDGETS_SITE_KEY} id="my-form">
18
+ * <input type="email" name="email" class="my-input" required />
19
+ * <button type="submit">Get updates</button>
20
+ * <p class="message"></p>
21
+ * </SubscribeForm>
22
+ * <script>
23
+ * document.getElementById('my-form')?.addEventListener('jamwidgets:success', (e) => {
24
+ * e.target.querySelector('.message').textContent = e.detail.message;
25
+ * });
26
+ * </script>
27
+ *
28
+ * Events:
29
+ * - jamwidgets:loading - Fired when submission starts
30
+ * - jamwidgets:success - Fired on success, detail contains { success, message }
31
+ * - jamwidgets:error - Fired on error, detail contains the Error object
32
+ */
33
+
34
+ interface Props {
35
+ /** Your site key (required) */
36
+ siteKey: string;
37
+ /** Base URL of your JamWidgets instance (default: 'https://jamwidgets.com') */
38
+ endpoint?: string;
39
+ /** HTML id attribute for the form */
40
+ id?: string;
41
+ /** CSS class for the form */
42
+ class?: string;
43
+ }
44
+
45
+ const DEFAULT_ENDPOINT = "https://jamwidgets.com";
46
+ const API_PATH = "/api/v1";
47
+
48
+ const {
49
+ siteKey,
50
+ endpoint = DEFAULT_ENDPOINT,
51
+ id,
52
+ class: className = "",
53
+ } = Astro.props;
54
+
55
+ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
56
+ ---
57
+
58
+ <form
59
+ id={id}
60
+ class:list={["jamwidgets-subscribe-form", className]}
61
+ data-jamwidgets-subscribe-form
62
+ data-endpoint={baseUrl}
63
+ data-site-key={siteKey}
64
+ >
65
+ <slot />
66
+
67
+ <!-- Honeypot field for spam protection -->
68
+ <div style="position: absolute; left: -9999px;" aria-hidden="true">
69
+ <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
70
+ </div>
71
+ </form>
72
+
73
+ <script>
74
+ interface SubscribeResponse {
75
+ success: boolean;
76
+ message: string;
77
+ }
78
+
79
+ // Get or create a visitor ID for anonymous users
80
+ const VISITOR_KEY = "jamwidgets_visitor_id";
81
+ function getVisitorId(): string {
82
+ let id = localStorage.getItem(VISITOR_KEY);
83
+ if (!id) {
84
+ id = crypto.randomUUID();
85
+ localStorage.setItem(VISITOR_KEY, id);
86
+ }
87
+ return id;
88
+ }
89
+
90
+ document.querySelectorAll("[data-jamwidgets-subscribe-form]").forEach((form) => {
91
+ form.addEventListener("submit", async (e) => {
92
+ e.preventDefault();
93
+
94
+ const formEl = e.target as HTMLFormElement;
95
+ const endpoint = formEl.dataset.endpoint;
96
+ const siteKey = formEl.dataset.siteKey;
97
+
98
+ if (!endpoint || !siteKey) {
99
+ console.error("JamWidgets SubscribeForm: missing siteKey or endpoint");
100
+ return;
101
+ }
102
+
103
+ // Collect form data
104
+ const formData = new FormData(formEl);
105
+ const data: Record<string, unknown> = {};
106
+ formData.forEach((value, key) => {
107
+ data[key] = value;
108
+ });
109
+
110
+ // Dispatch loading event
111
+ formEl.dispatchEvent(new CustomEvent("jamwidgets:loading", { bubbles: true }));
112
+
113
+ try {
114
+ const response = await fetch(`${endpoint}/subscribe`, {
115
+ method: "POST",
116
+ headers: {
117
+ "Content-Type": "application/json",
118
+ "X-JamWidgets-Key": siteKey,
119
+ "X-JamWidgets-Visitor": getVisitorId(),
120
+ },
121
+ body: JSON.stringify(data),
122
+ });
123
+
124
+ if (!response.ok) {
125
+ const errorText = await response.text();
126
+ throw new Error(errorText || "Subscription failed");
127
+ }
128
+
129
+ const result: SubscribeResponse = await response.json();
130
+
131
+ // Dispatch success event
132
+ formEl.dispatchEvent(
133
+ new CustomEvent("jamwidgets:success", {
134
+ bubbles: true,
135
+ detail: result,
136
+ }),
137
+ );
138
+
139
+ // Clear email input on success
140
+ const emailInput = formEl.querySelector('input[name="email"]') as HTMLInputElement | null;
141
+ if (emailInput) {
142
+ emailInput.value = "";
143
+ }
144
+ } catch (error) {
145
+ formEl.dispatchEvent(
146
+ new CustomEvent("jamwidgets:error", {
147
+ bubbles: true,
148
+ detail: error,
149
+ }),
150
+ );
151
+ }
152
+ });
153
+ });
154
+ </script>
@@ -0,0 +1,203 @@
1
+ ---
2
+ /**
3
+ * JamWidgets Views Component
4
+ *
5
+ * Tracks page views and displays view count.
6
+ * Automatically records a view when the page loads.
7
+ *
8
+ * @example
9
+ * <Views
10
+ * siteKey={import.meta.env.JAMWIDGETS_SITE_KEY}
11
+ * pageId={Astro.url.pathname}
12
+ * />
13
+ */
14
+
15
+ interface Props {
16
+ /** Your site key (required) */
17
+ siteKey: string;
18
+ /** Unique identifier for this page (e.g., slug or URL path) */
19
+ pageId: string;
20
+ /** Base URL of your JamWidgets instance (default: 'https://jamwidgets.com') */
21
+ endpoint?: string;
22
+ /** Show unique visitor count alongside total views (default: false) */
23
+ showUnique?: boolean;
24
+ /** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
25
+ theme?: "light" | "dark" | "auto";
26
+ /** CSS class to add to the container */
27
+ class?: string;
28
+ }
29
+
30
+ const DEFAULT_ENDPOINT = "https://jamwidgets.com";
31
+ const API_PATH = "/api/v1";
32
+
33
+ const {
34
+ siteKey,
35
+ pageId,
36
+ endpoint = DEFAULT_ENDPOINT,
37
+ showUnique = false,
38
+ theme = "light",
39
+ class: className = "",
40
+ } = Astro.props;
41
+
42
+ // Build the API URL
43
+ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
44
+ ---
45
+
46
+ <div
47
+ class:list={["jamwidgets-views", `jamwidgets-theme-${theme}`, className]}
48
+ data-jamwidgets-views
49
+ data-endpoint={baseUrl}
50
+ data-site-key={siteKey}
51
+ data-page-id={pageId}
52
+ data-show-unique={showUnique}
53
+ >
54
+ <span class="jamwidgets-views-icon">
55
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
56
+ <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
57
+ <circle cx="12" cy="12" r="3"/>
58
+ </svg>
59
+ </span>
60
+ <span class="jamwidgets-views-count">-</span>
61
+ <span class="jamwidgets-views-unique" style="display: none;"></span>
62
+ </div>
63
+
64
+ <script>
65
+ // Get or create a visitor ID for anonymous users
66
+ const VISITOR_KEY = "jamwidgets_visitor_id";
67
+ function getVisitorId(): string {
68
+ let id = localStorage.getItem(VISITOR_KEY);
69
+ if (!id) {
70
+ id = crypto.randomUUID();
71
+ localStorage.setItem(VISITOR_KEY, id);
72
+ }
73
+ return id;
74
+ }
75
+
76
+ document.querySelectorAll("[data-jamwidgets-views]").forEach((container) => {
77
+ const endpoint = (container as HTMLElement).dataset.endpoint;
78
+ const siteKey = (container as HTMLElement).dataset.siteKey;
79
+ const pageId = (container as HTMLElement).dataset.pageId;
80
+ const showUnique = (container as HTMLElement).dataset.showUnique === "true";
81
+
82
+ if (!endpoint || !siteKey || !pageId) return;
83
+
84
+ const visitorId = getVisitorId();
85
+ const headers = {
86
+ "X-JamWidgets-Key": siteKey,
87
+ "X-JamWidgets-Visitor": visitorId,
88
+ };
89
+
90
+ const countEl = container.querySelector(".jamwidgets-views-count");
91
+ const uniqueEl = container.querySelector(".jamwidgets-views-unique") as HTMLElement;
92
+
93
+ // Record the view
94
+ async function recordView() {
95
+ try {
96
+ const response = await fetch(
97
+ `${endpoint}/views/${encodeURIComponent(pageId!)}`,
98
+ {
99
+ method: "POST",
100
+ headers,
101
+ },
102
+ );
103
+ if (!response.ok) return;
104
+ const data = await response.json();
105
+ const result = data.record_view_response;
106
+
107
+ if (countEl) {
108
+ countEl.textContent = String(result?.views || 0);
109
+ }
110
+
111
+ if (showUnique && uniqueEl && result?.unique_visitors !== undefined) {
112
+ uniqueEl.textContent = `(${result.unique_visitors} unique)`;
113
+ uniqueEl.style.display = "";
114
+ }
115
+
116
+ container.dispatchEvent(
117
+ new CustomEvent("jamwidgets:view-recorded", {
118
+ detail: result,
119
+ }),
120
+ );
121
+ } catch (e) {
122
+ console.error("Failed to record view:", e);
123
+ // Fallback: try to load existing counts
124
+ loadView();
125
+ }
126
+ }
127
+
128
+ // Load view count without recording
129
+ async function loadView() {
130
+ try {
131
+ const response = await fetch(
132
+ `${endpoint}/views/${encodeURIComponent(pageId!)}`,
133
+ { headers },
134
+ );
135
+ if (!response.ok) return;
136
+ const data = await response.json();
137
+ const result = data.page_view_counts;
138
+
139
+ if (countEl) {
140
+ countEl.textContent = String(result?.views || 0);
141
+ }
142
+
143
+ if (showUnique && uniqueEl && result?.uniqueVisitors !== undefined) {
144
+ uniqueEl.textContent = `(${result.uniqueVisitors} unique)`;
145
+ uniqueEl.style.display = "";
146
+ }
147
+ } catch (e) {
148
+ console.error("Failed to load view:", e);
149
+ }
150
+ }
151
+
152
+ // Record the view on page load
153
+ recordView();
154
+ });
155
+ </script>
156
+
157
+ <style is:global>
158
+ @layer jamwidgets {
159
+ .jamwidgets-views {
160
+ --jamwidgets-text-color: #6b7280;
161
+ --jamwidgets-icon-color: #9ca3af;
162
+
163
+ display: inline-flex;
164
+ align-items: center;
165
+ gap: 0.375rem;
166
+ font-size: 0.875rem;
167
+ color: var(--jamwidgets-text-color);
168
+ }
169
+
170
+ /* Dark theme */
171
+ .jamwidgets-views.jamwidgets-theme-dark {
172
+ --jamwidgets-text-color: #9ca3af;
173
+ --jamwidgets-icon-color: #6b7280;
174
+ }
175
+
176
+ /* Auto theme - follows prefers-color-scheme */
177
+ @media (prefers-color-scheme: dark) {
178
+ .jamwidgets-views.jamwidgets-theme-auto {
179
+ --jamwidgets-text-color: #9ca3af;
180
+ --jamwidgets-icon-color: #6b7280;
181
+ }
182
+ }
183
+
184
+ .jamwidgets-views-icon {
185
+ display: flex;
186
+ color: var(--jamwidgets-icon-color);
187
+ }
188
+
189
+ .jamwidgets-views-icon svg {
190
+ width: 1em;
191
+ height: 1em;
192
+ }
193
+
194
+ .jamwidgets-views-count {
195
+ font-variant-numeric: tabular-nums;
196
+ }
197
+
198
+ .jamwidgets-views-unique {
199
+ color: var(--jamwidgets-icon-color);
200
+ font-size: 0.75rem;
201
+ }
202
+ }
203
+ </style>
@@ -0,0 +1,262 @@
1
+ ---
2
+ /**
3
+ * JamWidgets Waitlist Component
4
+ *
5
+ * Displays a waitlist signup form for collecting emails before launch.
6
+ *
7
+ * @example
8
+ * <Waitlist siteKey={import.meta.env.JAMWIDGETS_SITE_KEY} />
9
+ */
10
+
11
+ interface Props {
12
+ /** Your site key (required) */
13
+ siteKey: string;
14
+ /** Base URL of your JamWidgets instance (default: 'https://jamwidgets.com') */
15
+ endpoint?: string;
16
+ /** Theme preset: 'light' (default), 'dark', or 'auto' (uses prefers-color-scheme) */
17
+ theme?: "light" | "dark" | "auto";
18
+ /** Placeholder text for the email input (default: 'Enter your email') */
19
+ placeholder?: string;
20
+ /** Button text (default: 'Join Waitlist') */
21
+ buttonText?: string;
22
+ /** Success message (default: "You're on the list!") */
23
+ successMessage?: string;
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
+ endpoint = DEFAULT_ENDPOINT,
34
+ theme = "light",
35
+ placeholder = "Enter your email",
36
+ buttonText = "Join Waitlist",
37
+ successMessage = "You're on the list!",
38
+ class: className = "",
39
+ } = Astro.props;
40
+
41
+ // Build the API URL
42
+ const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
43
+ ---
44
+
45
+ <div
46
+ class:list={["jamwidgets-waitlist", `jamwidgets-theme-${theme}`, className]}
47
+ data-jamwidgets-waitlist
48
+ data-endpoint={baseUrl}
49
+ data-site-key={siteKey}
50
+ data-success-message={successMessage}
51
+ >
52
+ <form class="jamwidgets-waitlist-form">
53
+ <input
54
+ type="email"
55
+ name="email"
56
+ class="jamwidgets-waitlist-input"
57
+ placeholder={placeholder}
58
+ required
59
+ />
60
+ <button type="submit" class="jamwidgets-waitlist-submit">
61
+ {buttonText}
62
+ </button>
63
+ </form>
64
+ <div class="jamwidgets-waitlist-message" style="display: none;"></div>
65
+ </div>
66
+
67
+ <script>
68
+ // Get or create a visitor ID for anonymous users
69
+ const VISITOR_KEY = "jamwidgets_visitor_id";
70
+ function getVisitorId(): string {
71
+ let id = localStorage.getItem(VISITOR_KEY);
72
+ if (!id) {
73
+ id = crypto.randomUUID();
74
+ localStorage.setItem(VISITOR_KEY, id);
75
+ }
76
+ return id;
77
+ }
78
+
79
+ document.querySelectorAll("[data-jamwidgets-waitlist]").forEach((container) => {
80
+ const endpoint = (container as HTMLElement).dataset.endpoint;
81
+ const siteKey = (container as HTMLElement).dataset.siteKey;
82
+ const successMessage = (container as HTMLElement).dataset.successMessage || "You're on the list!";
83
+
84
+ if (!endpoint || !siteKey) return;
85
+
86
+ const visitorId = getVisitorId();
87
+ const headers = {
88
+ "X-JamWidgets-Key": siteKey,
89
+ "X-JamWidgets-Visitor": visitorId,
90
+ "Content-Type": "application/json",
91
+ };
92
+
93
+ const form = container.querySelector(".jamwidgets-waitlist-form") as HTMLFormElement;
94
+ const input = container.querySelector(".jamwidgets-waitlist-input") as HTMLInputElement;
95
+ const submitBtn = container.querySelector(".jamwidgets-waitlist-submit") as HTMLButtonElement;
96
+ const messageEl = container.querySelector(".jamwidgets-waitlist-message") as HTMLElement;
97
+
98
+ form.addEventListener("submit", async (e) => {
99
+ e.preventDefault();
100
+
101
+ const email = input.value.trim();
102
+ if (!email) return;
103
+
104
+ submitBtn.disabled = true;
105
+ const originalText = submitBtn.textContent;
106
+ submitBtn.textContent = "Joining...";
107
+ messageEl.style.display = "none";
108
+
109
+ try {
110
+ const response = await fetch(`${endpoint}/waitlist`, {
111
+ method: "POST",
112
+ headers,
113
+ body: JSON.stringify({ email }),
114
+ });
115
+
116
+ if (!response.ok) {
117
+ const error = await response.json();
118
+ throw new Error(error.message || "Failed to join waitlist");
119
+ }
120
+
121
+ // Hide form and show success
122
+ form.style.display = "none";
123
+ messageEl.textContent = successMessage;
124
+ messageEl.className = "jamwidgets-waitlist-message jamwidgets-success";
125
+ messageEl.style.display = "block";
126
+
127
+ container.dispatchEvent(
128
+ new CustomEvent("jamwidgets:waitlist-joined", {
129
+ detail: { email },
130
+ }),
131
+ );
132
+ } catch (e) {
133
+ console.error("Failed to join waitlist:", e);
134
+ messageEl.textContent = e instanceof Error ? e.message : "Failed to join waitlist";
135
+ messageEl.className = "jamwidgets-waitlist-message jamwidgets-error";
136
+ messageEl.style.display = "block";
137
+ submitBtn.disabled = false;
138
+ submitBtn.textContent = originalText;
139
+
140
+ setTimeout(() => {
141
+ messageEl.style.display = "none";
142
+ }, 3000);
143
+ }
144
+ });
145
+ });
146
+ </script>
147
+
148
+ <style is:global>
149
+ .jamwidgets-waitlist {
150
+ --jamwidgets-border-color: #9ca3af;
151
+ --jamwidgets-bg-color: white;
152
+ --jamwidgets-text-color: inherit;
153
+ --jamwidgets-text-muted: #6b7280;
154
+ --jamwidgets-button-bg: #3b82f6;
155
+ --jamwidgets-button-text: white;
156
+ --jamwidgets-button-hover: #2563eb;
157
+ --jamwidgets-success-color: #16a34a;
158
+ --jamwidgets-success-bg: #dcfce7;
159
+ --jamwidgets-error-color: #dc2626;
160
+ --jamwidgets-error-bg: #fef2f2;
161
+ font-family: inherit;
162
+ }
163
+
164
+ .jamwidgets-waitlist.jamwidgets-theme-dark {
165
+ --jamwidgets-border-color: #9ca3af;
166
+ --jamwidgets-bg-color: #374151;
167
+ --jamwidgets-text-color: #f3f4f6;
168
+ --jamwidgets-text-muted: #9ca3af;
169
+ --jamwidgets-success-color: #22c55e;
170
+ --jamwidgets-success-bg: rgba(34, 197, 94, 0.15);
171
+ --jamwidgets-error-color: #ef4444;
172
+ --jamwidgets-error-bg: rgba(239, 68, 68, 0.15);
173
+ }
174
+
175
+ @media (prefers-color-scheme: dark) {
176
+ .jamwidgets-waitlist.jamwidgets-theme-auto {
177
+ --jamwidgets-border-color: #9ca3af;
178
+ --jamwidgets-bg-color: #374151;
179
+ --jamwidgets-text-color: #f3f4f6;
180
+ --jamwidgets-text-muted: #9ca3af;
181
+ --jamwidgets-success-color: #22c55e;
182
+ --jamwidgets-success-bg: rgba(34, 197, 94, 0.15);
183
+ --jamwidgets-error-color: #ef4444;
184
+ --jamwidgets-error-bg: rgba(239, 68, 68, 0.15);
185
+ }
186
+ }
187
+
188
+ .jamwidgets-waitlist-form {
189
+ display: flex;
190
+ gap: 0.5rem;
191
+ }
192
+
193
+ .jamwidgets-waitlist .jamwidgets-waitlist-input {
194
+ flex: 1;
195
+ padding: 0.625rem 0.875rem;
196
+ font-size: 0.875rem;
197
+ border: 1px solid var(--jamwidgets-border-color);
198
+ border-radius: 0.5rem;
199
+ background: var(--jamwidgets-bg-color);
200
+ color: var(--jamwidgets-text-color);
201
+ font-family: inherit;
202
+ min-width: 0;
203
+ }
204
+
205
+ .jamwidgets-waitlist .jamwidgets-waitlist-input:focus {
206
+ outline: none;
207
+ border-color: var(--jamwidgets-button-bg);
208
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
209
+ }
210
+
211
+ .jamwidgets-waitlist .jamwidgets-waitlist-input::placeholder {
212
+ color: var(--jamwidgets-text-muted);
213
+ }
214
+
215
+ .jamwidgets-waitlist .jamwidgets-waitlist-submit {
216
+ padding: 0.625rem 1rem;
217
+ background: var(--jamwidgets-button-bg);
218
+ color: var(--jamwidgets-button-text);
219
+ border: none;
220
+ border-radius: 0.5rem;
221
+ font-size: 0.875rem;
222
+ font-weight: 500;
223
+ cursor: pointer;
224
+ transition: background 0.15s;
225
+ white-space: nowrap;
226
+ }
227
+
228
+ .jamwidgets-waitlist .jamwidgets-waitlist-submit:hover:not(:disabled) {
229
+ background: var(--jamwidgets-button-hover);
230
+ }
231
+
232
+ .jamwidgets-waitlist .jamwidgets-waitlist-submit:disabled {
233
+ opacity: 0.5;
234
+ cursor: not-allowed;
235
+ }
236
+
237
+ .jamwidgets-waitlist-message {
238
+ padding: 0.75rem 1rem;
239
+ font-size: 0.875rem;
240
+ border-radius: 0.5rem;
241
+ text-align: center;
242
+ }
243
+
244
+ .jamwidgets-waitlist-message.jamwidgets-success {
245
+ background: var(--jamwidgets-success-bg);
246
+ color: var(--jamwidgets-success-color);
247
+ }
248
+
249
+ .jamwidgets-waitlist-message.jamwidgets-error {
250
+ background: var(--jamwidgets-error-bg);
251
+ color: var(--jamwidgets-error-color);
252
+ }
253
+
254
+ @media (max-width: 400px) {
255
+ .jamwidgets-waitlist-form {
256
+ flex-direction: column;
257
+ }
258
+ .jamwidgets-waitlist .jamwidgets-waitlist-submit {
259
+ width: 100%;
260
+ }
261
+ }
262
+ </style>