@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/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +15 -0
- package/dist/loader.d.ts +62 -0
- package/dist/loader.js +74 -0
- package/package.json +65 -0
- package/src/Announcements.astro +233 -0
- package/src/Comments.astro +481 -0
- package/src/Embed.astro +513 -0
- package/src/Feedback.astro +327 -0
- package/src/Form.astro +237 -0
- package/src/Poll.astro +490 -0
- package/src/Reactions.astro +317 -0
- package/src/Subscribe.astro +314 -0
- package/src/SubscribeForm.astro +154 -0
- package/src/Views.astro +203 -0
- package/src/Waitlist.astro +262 -0
- package/src/index.ts +127 -0
- package/src/loader.ts +141 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* JamWidgets Announcements Component
|
|
4
|
+
*
|
|
5
|
+
* Displays site-wide announcements/banners.
|
|
6
|
+
* Supports dismissible announcements with localStorage persistence.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <Announcements 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
|
+
/** CSS class to add to the container */
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_ENDPOINT = "https://jamwidgets.com";
|
|
22
|
+
const API_PATH = "/api/v1";
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
siteKey,
|
|
26
|
+
endpoint = DEFAULT_ENDPOINT,
|
|
27
|
+
class: className = "",
|
|
28
|
+
} = Astro.props;
|
|
29
|
+
|
|
30
|
+
// Build the API URL
|
|
31
|
+
const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<div
|
|
35
|
+
class:list={["jamwidgets-announcements", className]}
|
|
36
|
+
data-jamwidgets-announcements
|
|
37
|
+
data-endpoint={baseUrl}
|
|
38
|
+
data-site-key={siteKey}
|
|
39
|
+
></div>
|
|
40
|
+
|
|
41
|
+
<script>
|
|
42
|
+
interface Announcement {
|
|
43
|
+
id: string;
|
|
44
|
+
content: string;
|
|
45
|
+
announcementType: "info" | "warning" | "success" | "error";
|
|
46
|
+
linkUrl?: string;
|
|
47
|
+
linkText?: string;
|
|
48
|
+
isDismissible: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Get or create a visitor ID for anonymous users
|
|
52
|
+
const VISITOR_KEY = "jamwidgets_visitor_id";
|
|
53
|
+
function getVisitorId(): string {
|
|
54
|
+
let id = localStorage.getItem(VISITOR_KEY);
|
|
55
|
+
if (!id) {
|
|
56
|
+
id = crypto.randomUUID();
|
|
57
|
+
localStorage.setItem(VISITOR_KEY, id);
|
|
58
|
+
}
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
document.querySelectorAll("[data-jamwidgets-announcements]").forEach((container) => {
|
|
63
|
+
const endpoint = (container as HTMLElement).dataset.endpoint;
|
|
64
|
+
const siteKey = (container as HTMLElement).dataset.siteKey;
|
|
65
|
+
|
|
66
|
+
if (!endpoint || !siteKey) return;
|
|
67
|
+
|
|
68
|
+
const visitorId = getVisitorId();
|
|
69
|
+
const headers = {
|
|
70
|
+
"X-JamWidgets-Key": siteKey,
|
|
71
|
+
"X-JamWidgets-Visitor": visitorId,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
async function loadAnnouncements() {
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(`${endpoint}/announcements`, { headers });
|
|
77
|
+
if (!response.ok) return;
|
|
78
|
+
const data = await response.json();
|
|
79
|
+
const announcements: Announcement[] = data.announcements || [];
|
|
80
|
+
renderAnnouncements(announcements);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error("Failed to load announcements:", e);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderAnnouncements(announcements: Announcement[]) {
|
|
87
|
+
container.innerHTML = "";
|
|
88
|
+
|
|
89
|
+
announcements.forEach((announcement) => {
|
|
90
|
+
const el = document.createElement("div");
|
|
91
|
+
el.className = `jamwidgets-announcement jamwidgets-announcement-${announcement.announcementType}`;
|
|
92
|
+
el.dataset.announcementId = announcement.id;
|
|
93
|
+
|
|
94
|
+
let content = `<span class="jamwidgets-announcement-content">${announcement.content}</span>`;
|
|
95
|
+
|
|
96
|
+
if (announcement.linkUrl) {
|
|
97
|
+
content += ` <a href="${announcement.linkUrl}" class="jamwidgets-announcement-link">${announcement.linkText || "Learn more"}</a>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (announcement.isDismissible) {
|
|
101
|
+
content += `
|
|
102
|
+
<button type="button" class="jamwidgets-announcement-dismiss" aria-label="Dismiss">
|
|
103
|
+
<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">
|
|
104
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
105
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
106
|
+
</svg>
|
|
107
|
+
</button>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
el.innerHTML = content;
|
|
112
|
+
|
|
113
|
+
// Handle dismiss
|
|
114
|
+
const dismissBtn = el.querySelector(".jamwidgets-announcement-dismiss");
|
|
115
|
+
dismissBtn?.addEventListener("click", async () => {
|
|
116
|
+
try {
|
|
117
|
+
await fetch(`${endpoint}/announcements/${announcement.id}/dismiss`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers,
|
|
120
|
+
});
|
|
121
|
+
el.remove();
|
|
122
|
+
container.dispatchEvent(
|
|
123
|
+
new CustomEvent("jamwidgets:announcement-dismissed", {
|
|
124
|
+
detail: { id: announcement.id },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error("Failed to dismiss announcement:", e);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
container.appendChild(el);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
loadAnnouncements();
|
|
137
|
+
});
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<style is:global>
|
|
141
|
+
@layer jamwidgets {
|
|
142
|
+
.jamwidgets-announcements {
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-direction: column;
|
|
145
|
+
gap: 0.5rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.jamwidgets-announcement {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 0.75rem;
|
|
152
|
+
padding: 0.75rem 1rem;
|
|
153
|
+
border-radius: 0.5rem;
|
|
154
|
+
font-size: 0.875rem;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.jamwidgets-announcement-info {
|
|
158
|
+
background: #dbeafe;
|
|
159
|
+
color: #1e40af;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.jamwidgets-announcement-warning {
|
|
163
|
+
background: #fef3c7;
|
|
164
|
+
color: #92400e;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.jamwidgets-announcement-success {
|
|
168
|
+
background: #dcfce7;
|
|
169
|
+
color: #166534;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.jamwidgets-announcement-error {
|
|
173
|
+
background: #fef2f2;
|
|
174
|
+
color: #991b1b;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.jamwidgets-announcement-content {
|
|
178
|
+
flex: 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.jamwidgets-announcement-link {
|
|
182
|
+
font-weight: 500;
|
|
183
|
+
text-decoration: underline;
|
|
184
|
+
text-underline-offset: 2px;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.jamwidgets-announcement-link:hover {
|
|
188
|
+
text-decoration: none;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.jamwidgets-announcement-dismiss {
|
|
192
|
+
padding: 0.25rem;
|
|
193
|
+
background: transparent;
|
|
194
|
+
border: none;
|
|
195
|
+
cursor: pointer;
|
|
196
|
+
opacity: 0.6;
|
|
197
|
+
transition: opacity 0.15s;
|
|
198
|
+
display: flex;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.jamwidgets-announcement-dismiss:hover {
|
|
202
|
+
opacity: 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.jamwidgets-announcement-dismiss svg {
|
|
206
|
+
width: 1rem;
|
|
207
|
+
height: 1rem;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* Dark mode adjustments */
|
|
211
|
+
@media (prefers-color-scheme: dark) {
|
|
212
|
+
.jamwidgets-announcement-info {
|
|
213
|
+
background: rgba(59, 130, 246, 0.2);
|
|
214
|
+
color: #93c5fd;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.jamwidgets-announcement-warning {
|
|
218
|
+
background: rgba(245, 158, 11, 0.2);
|
|
219
|
+
color: #fcd34d;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.jamwidgets-announcement-success {
|
|
223
|
+
background: rgba(34, 197, 94, 0.2);
|
|
224
|
+
color: #86efac;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.jamwidgets-announcement-error {
|
|
228
|
+
background: rgba(239, 68, 68, 0.2);
|
|
229
|
+
color: #fca5a5;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
</style>
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* JamWidgets Comments Component
|
|
4
|
+
*
|
|
5
|
+
* Displays threaded comments with a form to post new comments and replies.
|
|
6
|
+
* Customize with CSS custom properties.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <Comments siteKey={import.meta.env.JAMWIDGETS_SITE_KEY} pageId={Astro.url.pathname} />
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Your site key (required) */
|
|
14
|
+
siteKey: string;
|
|
15
|
+
/** Unique identifier for this page (e.g., slug or URL path) */
|
|
16
|
+
pageId: 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
|
+
/** CSS class to add to the container */
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ENDPOINT = "https://jamwidgets.com";
|
|
26
|
+
const API_PATH = "/api/v1";
|
|
27
|
+
|
|
28
|
+
const {
|
|
29
|
+
siteKey,
|
|
30
|
+
pageId,
|
|
31
|
+
endpoint = DEFAULT_ENDPOINT,
|
|
32
|
+
theme = "light",
|
|
33
|
+
class: className = "",
|
|
34
|
+
} = Astro.props;
|
|
35
|
+
|
|
36
|
+
// Build the API URL
|
|
37
|
+
const baseUrl = endpoint.replace(/\/+$/, "") + API_PATH;
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
<div
|
|
41
|
+
class:list={["jamwidgets-comments", `jamwidgets-theme-${theme}`, className]}
|
|
42
|
+
data-jamwidgets-comments
|
|
43
|
+
data-endpoint={baseUrl}
|
|
44
|
+
data-site-key={siteKey}
|
|
45
|
+
data-page-id={pageId}
|
|
46
|
+
>
|
|
47
|
+
<div class="jamwidgets-comments-list"></div>
|
|
48
|
+
|
|
49
|
+
<form class="jamwidgets-comments-form">
|
|
50
|
+
<div class="jamwidgets-reply-indicator" style="display: none;">
|
|
51
|
+
<span>Replying to <strong class="jamwidgets-reply-to-name"></strong></span>
|
|
52
|
+
<button type="button" class="jamwidgets-cancel-reply">Cancel</button>
|
|
53
|
+
</div>
|
|
54
|
+
<input type="hidden" name="parentId" value="" />
|
|
55
|
+
<slot name="form">
|
|
56
|
+
<div class="jamwidgets-form-group">
|
|
57
|
+
<label for="jamwidgets-author-name">Name</label>
|
|
58
|
+
<input type="text" id="jamwidgets-author-name" name="authorName" required />
|
|
59
|
+
</div>
|
|
60
|
+
<div class="jamwidgets-form-group">
|
|
61
|
+
<label for="jamwidgets-author-email">Email (optional)</label>
|
|
62
|
+
<input type="email" id="jamwidgets-author-email" name="authorEmail" />
|
|
63
|
+
</div>
|
|
64
|
+
<div class="jamwidgets-form-group">
|
|
65
|
+
<label for="jamwidgets-content">Comment</label>
|
|
66
|
+
<textarea id="jamwidgets-content" name="content" required rows="3"></textarea>
|
|
67
|
+
</div>
|
|
68
|
+
<button type="submit">Post Comment</button>
|
|
69
|
+
</slot>
|
|
70
|
+
|
|
71
|
+
<!-- Honeypot field for spam protection (hidden from users, catches bots) -->
|
|
72
|
+
<div style="position: absolute; left: -9999px;" aria-hidden="true">
|
|
73
|
+
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" />
|
|
74
|
+
</div>
|
|
75
|
+
</form>
|
|
76
|
+
|
|
77
|
+
<template id="jamwidgets-comment-template">
|
|
78
|
+
<div class="jamwidgets-comment">
|
|
79
|
+
<div class="jamwidgets-comment-header">
|
|
80
|
+
<span class="jamwidgets-comment-author"></span>
|
|
81
|
+
<span class="jamwidgets-comment-date"></span>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="jamwidgets-comment-content"></div>
|
|
84
|
+
<button type="button" class="jamwidgets-reply-btn">Reply</button>
|
|
85
|
+
<div class="jamwidgets-comment-replies"></div>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<script>
|
|
91
|
+
interface Comment {
|
|
92
|
+
id: string;
|
|
93
|
+
authorName: string;
|
|
94
|
+
content: string;
|
|
95
|
+
createdAt: string;
|
|
96
|
+
replies: Comment[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Get or create a visitor ID for anonymous users
|
|
100
|
+
const VISITOR_KEY = "jamwidgets_visitor_id";
|
|
101
|
+
function getVisitorId(): string {
|
|
102
|
+
let id = localStorage.getItem(VISITOR_KEY);
|
|
103
|
+
if (!id) {
|
|
104
|
+
id = crypto.randomUUID();
|
|
105
|
+
localStorage.setItem(VISITOR_KEY, id);
|
|
106
|
+
}
|
|
107
|
+
return id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Store form load timestamps for spam detection
|
|
111
|
+
const formTimestamps = new WeakMap<Element, number>();
|
|
112
|
+
|
|
113
|
+
document.querySelectorAll("[data-jamwidgets-comments]").forEach((container) => {
|
|
114
|
+
const endpoint = (container as HTMLElement).dataset.endpoint;
|
|
115
|
+
const siteKey = (container as HTMLElement).dataset.siteKey;
|
|
116
|
+
const pageId = (container as HTMLElement).dataset.pageId;
|
|
117
|
+
const list = container.querySelector(".jamwidgets-comments-list");
|
|
118
|
+
const form = container.querySelector(".jamwidgets-comments-form") as HTMLFormElement;
|
|
119
|
+
const template = container.querySelector("#jamwidgets-comment-template") as HTMLTemplateElement;
|
|
120
|
+
const replyIndicator = container.querySelector(".jamwidgets-reply-indicator") as HTMLElement;
|
|
121
|
+
const replyToName = container.querySelector(".jamwidgets-reply-to-name") as HTMLElement;
|
|
122
|
+
const cancelReplyBtn = container.querySelector(".jamwidgets-cancel-reply") as HTMLButtonElement;
|
|
123
|
+
const parentIdInput = form?.querySelector('input[name="parentId"]') as HTMLInputElement;
|
|
124
|
+
|
|
125
|
+
if (!endpoint || !siteKey || !pageId || !list || !form || !template) return;
|
|
126
|
+
|
|
127
|
+
const visitorId = getVisitorId();
|
|
128
|
+
const headers = {
|
|
129
|
+
"X-JamWidgets-Key": siteKey,
|
|
130
|
+
"X-JamWidgets-Visitor": visitorId,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Record when form was loaded (for time-based spam detection)
|
|
134
|
+
formTimestamps.set(form, Math.floor(Date.now() / 1000));
|
|
135
|
+
|
|
136
|
+
// Cancel reply mode
|
|
137
|
+
function cancelReply() {
|
|
138
|
+
if (parentIdInput) parentIdInput.value = "";
|
|
139
|
+
if (replyIndicator) replyIndicator.style.display = "none";
|
|
140
|
+
const submitBtn = form.querySelector('[type="submit"]') as HTMLButtonElement;
|
|
141
|
+
if (submitBtn) submitBtn.textContent = "Post Comment";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cancelReplyBtn?.addEventListener("click", cancelReply);
|
|
145
|
+
|
|
146
|
+
async function loadComments() {
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetch(
|
|
149
|
+
`${endpoint}/comments/${encodeURIComponent(pageId!)}`,
|
|
150
|
+
{ headers },
|
|
151
|
+
);
|
|
152
|
+
if (!response.ok) return;
|
|
153
|
+
const data = await response.json();
|
|
154
|
+
renderComments(data.comment_threads || []);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.error("Failed to load comments:", e);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderComments(comments: Comment[]) {
|
|
161
|
+
list!.innerHTML = "";
|
|
162
|
+
if (comments.length === 0) {
|
|
163
|
+
const empty = document.createElement("p");
|
|
164
|
+
empty.className = "jamwidgets-comments-empty";
|
|
165
|
+
empty.textContent = "No comments yet. Be the first!";
|
|
166
|
+
list!.appendChild(empty);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
comments.forEach((comment) => renderComment(comment, list!));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderComment(comment: Comment, parent: Element) {
|
|
173
|
+
const clone = template!.content.cloneNode(true) as DocumentFragment;
|
|
174
|
+
const el = clone.querySelector(".jamwidgets-comment") as HTMLElement;
|
|
175
|
+
el.dataset.commentId = comment.id;
|
|
176
|
+
el.querySelector(".jamwidgets-comment-author")!.textContent = comment.authorName;
|
|
177
|
+
el.querySelector(".jamwidgets-comment-date")!.textContent = new Date(
|
|
178
|
+
comment.createdAt,
|
|
179
|
+
).toLocaleDateString();
|
|
180
|
+
el.querySelector(".jamwidgets-comment-content")!.textContent = comment.content;
|
|
181
|
+
|
|
182
|
+
// Add reply button handler
|
|
183
|
+
const replyBtn = el.querySelector(".jamwidgets-reply-btn") as HTMLButtonElement;
|
|
184
|
+
replyBtn?.addEventListener("click", () => {
|
|
185
|
+
if (parentIdInput) parentIdInput.value = comment.id;
|
|
186
|
+
if (replyToName) replyToName.textContent = comment.authorName;
|
|
187
|
+
if (replyIndicator) replyIndicator.style.display = "flex";
|
|
188
|
+
const submitBtn = form.querySelector('[type="submit"]') as HTMLButtonElement;
|
|
189
|
+
if (submitBtn) submitBtn.textContent = "Post Reply";
|
|
190
|
+
// Scroll form into view and focus
|
|
191
|
+
form.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
192
|
+
const contentField = form.querySelector('textarea[name="content"]') as HTMLTextAreaElement;
|
|
193
|
+
setTimeout(() => contentField?.focus(), 300);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const repliesContainer = el.querySelector(".jamwidgets-comment-replies")!;
|
|
197
|
+
comment.replies?.forEach((reply) => renderComment(reply, repliesContainer));
|
|
198
|
+
|
|
199
|
+
parent.appendChild(clone);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
form.addEventListener("submit", async (e) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
const formEl = e.target as HTMLFormElement;
|
|
205
|
+
const formData = new FormData(formEl);
|
|
206
|
+
const submitBtn = formEl.querySelector('[type="submit"]') as HTMLButtonElement;
|
|
207
|
+
|
|
208
|
+
if (submitBtn) submitBtn.disabled = true;
|
|
209
|
+
|
|
210
|
+
// Get honeypot value (should be empty for real users)
|
|
211
|
+
const honeypot = formData.get("_gotcha") as string | null;
|
|
212
|
+
|
|
213
|
+
// Get load timestamp for time-based spam detection
|
|
214
|
+
const loadTimestamp = formTimestamps.get(formEl);
|
|
215
|
+
|
|
216
|
+
// Get parent ID if replying
|
|
217
|
+
const parentId = formData.get("parentId") as string | null;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const response = await fetch(
|
|
221
|
+
`${endpoint}/comments/${encodeURIComponent(pageId)}`,
|
|
222
|
+
{
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: {
|
|
225
|
+
...headers,
|
|
226
|
+
"Content-Type": "application/json",
|
|
227
|
+
},
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
authorName: formData.get("authorName"),
|
|
230
|
+
authorEmail: formData.get("authorEmail") || undefined,
|
|
231
|
+
content: formData.get("content"),
|
|
232
|
+
parentId: parentId ? parseInt(parentId, 10) : undefined,
|
|
233
|
+
_gotcha: honeypot || undefined,
|
|
234
|
+
_jamwidgets_ts: loadTimestamp,
|
|
235
|
+
}),
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
if (!response.ok) throw new Error("Failed to post comment");
|
|
240
|
+
|
|
241
|
+
formEl.reset();
|
|
242
|
+
cancelReply(); // Reset reply mode
|
|
243
|
+
container.dispatchEvent(
|
|
244
|
+
new CustomEvent("jamwidgets:comment-posted", {
|
|
245
|
+
detail: await response.json(),
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Show pending notice (comment may need approval)
|
|
250
|
+
const notice = document.createElement("p");
|
|
251
|
+
notice.className = "jamwidgets-comment-notice";
|
|
252
|
+
notice.textContent = "Thanks! Your comment is pending approval.";
|
|
253
|
+
formEl.appendChild(notice);
|
|
254
|
+
setTimeout(() => notice.remove(), 5000);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.error("Failed to post comment:", e);
|
|
257
|
+
} finally {
|
|
258
|
+
if (submitBtn) submitBtn.disabled = false;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
loadComments();
|
|
263
|
+
});
|
|
264
|
+
</script>
|
|
265
|
+
|
|
266
|
+
<style is:global>
|
|
267
|
+
.jamwidgets-comments {
|
|
268
|
+
--jamwidgets-border-color: #e5e7eb;
|
|
269
|
+
--jamwidgets-bg-color: #f9fafb;
|
|
270
|
+
--jamwidgets-text-color: inherit;
|
|
271
|
+
--jamwidgets-text-muted: #6b7280;
|
|
272
|
+
--jamwidgets-input-bg: white;
|
|
273
|
+
--jamwidgets-input-border: #9ca3af;
|
|
274
|
+
--jamwidgets-focus-color: #3b82f6;
|
|
275
|
+
--jamwidgets-focus-ring: rgba(59, 130, 246, 0.2);
|
|
276
|
+
--jamwidgets-button-bg: #3b82f6;
|
|
277
|
+
--jamwidgets-button-hover: #2563eb;
|
|
278
|
+
--jamwidgets-button-text: white;
|
|
279
|
+
--jamwidgets-notice-bg: #fef3c7;
|
|
280
|
+
--jamwidgets-notice-text: #92400e;
|
|
281
|
+
color: var(--jamwidgets-text-color);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.jamwidgets-comments.jamwidgets-theme-dark {
|
|
285
|
+
--jamwidgets-border-color: #4b5563;
|
|
286
|
+
--jamwidgets-bg-color: transparent;
|
|
287
|
+
--jamwidgets-text-color: #f3f4f6;
|
|
288
|
+
--jamwidgets-text-muted: #9ca3af;
|
|
289
|
+
--jamwidgets-input-bg: #374151;
|
|
290
|
+
--jamwidgets-input-border: #9ca3af;
|
|
291
|
+
--jamwidgets-focus-color: #60a5fa;
|
|
292
|
+
--jamwidgets-focus-ring: rgba(96, 165, 250, 0.3);
|
|
293
|
+
--jamwidgets-button-bg: #3b82f6;
|
|
294
|
+
--jamwidgets-button-hover: #60a5fa;
|
|
295
|
+
--jamwidgets-button-text: white;
|
|
296
|
+
--jamwidgets-notice-bg: #422006;
|
|
297
|
+
--jamwidgets-notice-text: #fcd34d;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
@media (prefers-color-scheme: dark) {
|
|
301
|
+
.jamwidgets-comments.jamwidgets-theme-auto {
|
|
302
|
+
--jamwidgets-border-color: #4b5563;
|
|
303
|
+
--jamwidgets-bg-color: transparent;
|
|
304
|
+
--jamwidgets-text-color: #f3f4f6;
|
|
305
|
+
--jamwidgets-text-muted: #9ca3af;
|
|
306
|
+
--jamwidgets-input-bg: #374151;
|
|
307
|
+
--jamwidgets-input-border: #9ca3af;
|
|
308
|
+
--jamwidgets-focus-color: #60a5fa;
|
|
309
|
+
--jamwidgets-focus-ring: rgba(96, 165, 250, 0.3);
|
|
310
|
+
--jamwidgets-button-bg: #3b82f6;
|
|
311
|
+
--jamwidgets-button-hover: #60a5fa;
|
|
312
|
+
--jamwidgets-button-text: white;
|
|
313
|
+
--jamwidgets-notice-bg: #422006;
|
|
314
|
+
--jamwidgets-notice-text: #fcd34d;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.jamwidgets-comments-form {
|
|
319
|
+
margin-top: 1rem;
|
|
320
|
+
padding-top: 1rem;
|
|
321
|
+
border-top: 1px solid var(--jamwidgets-border-color);
|
|
322
|
+
display: grid;
|
|
323
|
+
grid-template-columns: 1fr;
|
|
324
|
+
gap: 0.5rem;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
@media (min-width: 480px) {
|
|
328
|
+
.jamwidgets-comments-form {
|
|
329
|
+
grid-template-columns: 1fr 1fr;
|
|
330
|
+
}
|
|
331
|
+
.jamwidgets-comments-form .jamwidgets-form-group:has(textarea),
|
|
332
|
+
.jamwidgets-comments-form .jamwidgets-reply-indicator,
|
|
333
|
+
.jamwidgets-comments-form button[type="submit"] {
|
|
334
|
+
grid-column: 1 / -1;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.jamwidgets-reply-indicator {
|
|
339
|
+
display: flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
gap: 0.5rem;
|
|
342
|
+
padding: 0.5rem 0.75rem;
|
|
343
|
+
background: var(--jamwidgets-notice-bg);
|
|
344
|
+
color: var(--jamwidgets-notice-text);
|
|
345
|
+
border-radius: 0.25rem;
|
|
346
|
+
font-size: 0.8125rem;
|
|
347
|
+
grid-column: 1 / -1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.jamwidgets-cancel-reply {
|
|
351
|
+
margin-left: auto;
|
|
352
|
+
padding: 0.125rem 0.5rem;
|
|
353
|
+
background: transparent;
|
|
354
|
+
border: 1px solid currentColor;
|
|
355
|
+
border-radius: 0.25rem;
|
|
356
|
+
color: inherit;
|
|
357
|
+
font-size: 0.75rem;
|
|
358
|
+
cursor: pointer;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.jamwidgets-form-group {
|
|
362
|
+
margin-bottom: 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.jamwidgets-form-group label {
|
|
366
|
+
display: block;
|
|
367
|
+
margin-bottom: 0.25rem;
|
|
368
|
+
font-weight: 500;
|
|
369
|
+
font-size: 0.75rem;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.jamwidgets-comments .jamwidgets-form-group input,
|
|
373
|
+
.jamwidgets-comments .jamwidgets-form-group textarea {
|
|
374
|
+
width: 100%;
|
|
375
|
+
padding: 0.5rem 0.75rem;
|
|
376
|
+
border: 1px solid var(--jamwidgets-input-border);
|
|
377
|
+
border-radius: 0.375rem;
|
|
378
|
+
font-size: 0.875rem;
|
|
379
|
+
background: var(--jamwidgets-input-bg);
|
|
380
|
+
color: var(--jamwidgets-text-color);
|
|
381
|
+
font-family: inherit;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.jamwidgets-comments .jamwidgets-form-group textarea {
|
|
385
|
+
min-height: 5rem;
|
|
386
|
+
resize: vertical;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.jamwidgets-comments .jamwidgets-form-group input:focus,
|
|
390
|
+
.jamwidgets-comments .jamwidgets-form-group textarea:focus {
|
|
391
|
+
outline: none;
|
|
392
|
+
border-color: var(--jamwidgets-focus-color);
|
|
393
|
+
box-shadow: 0 0 0 2px var(--jamwidgets-focus-ring);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.jamwidgets-comments .jamwidgets-comments-form button[type="submit"] {
|
|
397
|
+
padding: 0.5rem 1rem;
|
|
398
|
+
background: var(--jamwidgets-button-bg);
|
|
399
|
+
color: var(--jamwidgets-button-text);
|
|
400
|
+
border: none;
|
|
401
|
+
border-radius: 0.375rem;
|
|
402
|
+
font-weight: 500;
|
|
403
|
+
font-size: 0.875rem;
|
|
404
|
+
cursor: pointer;
|
|
405
|
+
transition: background 0.15s;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.jamwidgets-comments .jamwidgets-comments-form button[type="submit"]:hover {
|
|
409
|
+
background: var(--jamwidgets-button-hover);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.jamwidgets-comments .jamwidgets-comments-form button[type="submit"]:disabled {
|
|
413
|
+
opacity: 0.6;
|
|
414
|
+
cursor: not-allowed;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.jamwidgets-comment {
|
|
418
|
+
padding: 0.75rem;
|
|
419
|
+
background: var(--jamwidgets-bg-color);
|
|
420
|
+
border: 1px solid var(--jamwidgets-border-color);
|
|
421
|
+
border-radius: 0.375rem;
|
|
422
|
+
margin-bottom: 0.5rem;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.jamwidgets-comment-header {
|
|
426
|
+
display: flex;
|
|
427
|
+
gap: 0.5rem;
|
|
428
|
+
align-items: center;
|
|
429
|
+
margin-bottom: 0.25rem;
|
|
430
|
+
font-size: 0.8125rem;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.jamwidgets-comment-author {
|
|
434
|
+
font-weight: 600;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.jamwidgets-comment-date {
|
|
438
|
+
color: var(--jamwidgets-text-muted);
|
|
439
|
+
font-size: 0.75rem;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.jamwidgets-comment-content {
|
|
443
|
+
white-space: pre-wrap;
|
|
444
|
+
font-size: 0.875rem;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.jamwidgets-reply-btn {
|
|
448
|
+
margin-top: 0.5rem;
|
|
449
|
+
padding: 0.125rem 0.5rem;
|
|
450
|
+
background: transparent;
|
|
451
|
+
border: none;
|
|
452
|
+
color: var(--jamwidgets-text-muted);
|
|
453
|
+
font-size: 0.75rem;
|
|
454
|
+
cursor: pointer;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.jamwidgets-reply-btn:hover {
|
|
458
|
+
color: var(--jamwidgets-focus-color);
|
|
459
|
+
text-decoration: underline;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.jamwidgets-comment-replies {
|
|
463
|
+
margin-left: 1rem;
|
|
464
|
+
margin-top: 0.5rem;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.jamwidgets-comments-empty {
|
|
468
|
+
color: var(--jamwidgets-text-muted);
|
|
469
|
+
font-style: italic;
|
|
470
|
+
font-size: 0.875rem;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.jamwidgets-comment-notice {
|
|
474
|
+
margin-top: 0.5rem;
|
|
475
|
+
padding: 0.375rem 0.5rem;
|
|
476
|
+
background: var(--jamwidgets-notice-bg);
|
|
477
|
+
color: var(--jamwidgets-notice-text);
|
|
478
|
+
border-radius: 0.25rem;
|
|
479
|
+
font-size: 0.8125rem;
|
|
480
|
+
}
|
|
481
|
+
</style>
|