@ibalzam/codejitsu-core 0.5.0 → 0.6.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.
@@ -92,6 +92,16 @@ export async function runForms(ctx) {
92
92
  );
93
93
  }
94
94
 
95
+ // Static-site reCAPTCHA reminder. We can't check EmailJS's dashboard from
96
+ // here, but we can flag the assumption so the human verifies it.
97
+ const recaptchaForms = forms.filter((x) => x.hasCaptcha);
98
+ if (recaptchaForms.length > 0) {
99
+ results.push(info(
100
+ `${recaptchaForms.length} form(s) use reCAPTCHA`,
101
+ 'Verify EmailJS template has "Verify reCAPTCHA" toggle ON with the SECRET key — otherwise the widget is theater on a static site (no server-side validation).'
102
+ ));
103
+ }
104
+
95
105
  // Consent (if required).
96
106
  if (formCfg.requireConsent === true) {
97
107
  const noConsent = forms.filter((x) => !x.hasConsent);
@@ -14,8 +14,24 @@ export interface CodejitsuConfig {
14
14
  images?: ImagesConfig | false;
15
15
  llms?: LlmsConfig | false;
16
16
  deploy?: DeployConfig | false;
17
+ contact?: ContactConfig | false;
17
18
  audit?: AuditConfig;
18
19
  }
20
+ export interface ContactConfig {
21
+ enabled?: boolean;
22
+ emailjs: {
23
+ /** EmailJS service ID, e.g. 'service_abc123'. */
24
+ serviceId: string;
25
+ /** EmailJS template ID, e.g. 'template_xyz789'. Template variables must be {{name}}, {{email}}, {{phone}}, {{message}}. */
26
+ templateId: string;
27
+ /** EmailJS public key. Safe to ship to the browser. */
28
+ publicKey: string;
29
+ };
30
+ /** Optional reCAPTCHA v2 sitekey. If set, the modal renders a captcha widget and blocks submit until solved. */
31
+ recaptcha?: {
32
+ siteKey: string;
33
+ };
34
+ }
19
35
  export interface AuditConfig {
20
36
  /** Per-provider requirement. 'optional' = pass either way; 'required' = fail if absent; 'banned' = fail if present. */
21
37
  analytics?: {
@@ -15,9 +15,26 @@ export interface CodejitsuConfig {
15
15
  images?: ImagesConfig | false;
16
16
  llms?: LlmsConfig | false;
17
17
  deploy?: DeployConfig | false;
18
+ contact?: ContactConfig | false;
18
19
  audit?: AuditConfig;
19
20
  }
20
21
 
22
+ export interface ContactConfig {
23
+ enabled?: boolean;
24
+ emailjs: {
25
+ /** EmailJS service ID, e.g. 'service_abc123'. */
26
+ serviceId: string;
27
+ /** EmailJS template ID, e.g. 'template_xyz789'. Template variables must be {{name}}, {{email}}, {{phone}}, {{message}}. */
28
+ templateId: string;
29
+ /** EmailJS public key. Safe to ship to the browser. */
30
+ publicKey: string;
31
+ };
32
+ /** Optional reCAPTCHA v2 sitekey. If set, the modal renders a captcha widget and blocks submit until solved. */
33
+ recaptcha?: {
34
+ siteKey: string;
35
+ };
36
+ }
37
+
21
38
  export interface AuditConfig {
22
39
  /** Per-provider requirement. 'optional' = pass either way; 'required' = fail if absent; 'banned' = fail if present. */
23
40
  analytics?: {
@@ -0,0 +1,164 @@
1
+ # Contact module — instructions for Claude
2
+
3
+ When the user asks to **add a contact form** (or quote modal, lead capture, "implement codejitsu/core/contact"), do the following.
4
+
5
+ ## What this module provides
6
+
7
+ A single, accessible contact modal component that:
8
+
9
+ - Renders a centered modal with optional left-side image
10
+ - Configurable fields (name, email, phone, message) — each enabled/required
11
+ - Configurable title, submit button text, thank-you toast
12
+ - HTML5 validation + custom hidden honeypot
13
+ - Optional Google reCAPTCHA v2
14
+ - Submits via [EmailJS](https://www.emailjs.com/) (`emailjs.sendForm`)
15
+ - Dispatches `codejitsu-contact-submitted` event on success (sites wire analytics)
16
+ - Full focus trap, Esc to close, backdrop click, focus restoration
17
+
18
+ One modal per page (per id). Triggered from any `<button data-codejitsu-contact-trigger>` element.
19
+
20
+ ## Wiring it into an Astro site
21
+
22
+ ### 1. Set up EmailJS
23
+
24
+ (Site owner does this once, not Claude.) Sign up at https://www.emailjs.com/, create:
25
+ - a service (e.g. Gmail, SMTP)
26
+ - a template that uses these template variables: `{{name}}`, `{{email}}`, `{{phone}}`, `{{message}}`
27
+ - copy the service ID, template ID, and public key
28
+
29
+ ### 1a. CRITICAL — reCAPTCHA on a static site only works if EmailJS verifies it
30
+
31
+ The modal shows the reCAPTCHA widget client-side, but **without server-side
32
+ verification of the token, the widget is theater**. A static site has no server
33
+ to run the verification. EmailJS provides this verification as a service — you
34
+ must enable it explicitly:
35
+
36
+ 1. EmailJS dashboard → **Email Templates** → your template → **Settings** tab
37
+ 2. Toggle **"Verify reCAPTCHA"** on
38
+ 3. Paste your reCAPTCHA **secret key** (NOT the sitekey — the secret key, found
39
+ alongside the sitekey in Google's reCAPTCHA admin)
40
+ 4. Save
41
+
42
+ Now EmailJS rejects submissions with invalid tokens before sending the email.
43
+
44
+ If you SKIP this step, leave reCAPTCHA out of the modal entirely. Use the
45
+ honeypot (always on) + EmailJS rate limits as your spam defense. A
46
+ non-verified reCAPTCHA widget is friction with no real benefit and breaks on
47
+ localhost during dev.
48
+
49
+ ### 2. Drop the modal into a layout
50
+
51
+ In a layout that wraps every page (e.g. `src/layouts/BaseLayout.astro`), import and place the component **once**, anywhere inside `<body>` (typically just before `</body>`):
52
+
53
+ ```astro
54
+ ---
55
+ import ContactModal from '@ibalzam/codejitsu-core/contact/ContactModal.astro';
56
+ import config from '../../codejitsu.config';
57
+ ---
58
+
59
+ <!-- ... existing layout content ... -->
60
+
61
+ <ContactModal
62
+ title="Get a Free Quote"
63
+ image={{ src: '/assets/images/contact.webp', alt: 'Our team' }}
64
+ fields={{
65
+ name: { required: true },
66
+ email: { required: true },
67
+ phone: { required: true },
68
+ message: { required: false },
69
+ }}
70
+ submitText="Submit Quote Request"
71
+ thankYouMessage="Thanks! We'll be in touch within 24 hours."
72
+ emailjs={{
73
+ serviceId: config.contact.emailjs.serviceId,
74
+ templateId: config.contact.emailjs.templateId,
75
+ publicKey: config.contact.emailjs.publicKey,
76
+ }}
77
+ recaptcha={config.contact.recaptcha}
78
+ />
79
+ ```
80
+
81
+ Pass the EmailJS keys via `codejitsu.config.ts` so they're declared once, not hardcoded in every layout.
82
+
83
+ ### 3. Add EmailJS keys to `codejitsu.config.ts`
84
+
85
+ ```ts
86
+ import { defineConfig } from '@ibalzam/codejitsu-core/config';
87
+
88
+ export default defineConfig({
89
+ // ...
90
+ contact: {
91
+ emailjs: {
92
+ serviceId: 'service_xxx',
93
+ templateId: 'template_xxx',
94
+ publicKey: 'xxx', // safe to ship to browser (public)
95
+ },
96
+ recaptcha: {
97
+ siteKey: '6Lxxxxxxxxxxxxxxx', // optional
98
+ },
99
+ },
100
+ });
101
+ ```
102
+
103
+ ### 4. Add triggers anywhere
104
+
105
+ Any clickable element with `data-codejitsu-contact-trigger`:
106
+
107
+ ```html
108
+ <button data-codejitsu-contact-trigger>Get a quote</button>
109
+ <a href="#contact" data-codejitsu-contact-trigger>Talk to us</a>
110
+ ```
111
+
112
+ ### 5. Optional: wire analytics
113
+
114
+ The component dispatches a `codejitsu-contact-submitted` event on success. Add a listener for GA4 / Google Ads / etc.:
115
+
116
+ ```html
117
+ <script is:inline>
118
+ window.addEventListener('codejitsu-contact-submitted', (e) => {
119
+ // e.detail = { modalId, formData: { name, email, phone, message } }
120
+ if (typeof gtag === 'function') {
121
+ gtag('event', 'conversion', { send_to: 'AW-XXXXXXXX/XXXXXX' });
122
+ }
123
+ });
124
+ </script>
125
+ ```
126
+
127
+ The component itself stays generic — no analytics inside it.
128
+
129
+ ## Theming
130
+
131
+ The component uses Tailwind classes for layout and CSS variables for brand colors. Set on `:root` in your global CSS:
132
+
133
+ ```css
134
+ :root {
135
+ --codejitsu-modal-accent: #YOUR_BRAND; /* button bg + focus ring */
136
+ --codejitsu-modal-accent-hover: #DARKER;
137
+ --codejitsu-modal-on-accent: #ffffff; /* text on the button */
138
+ }
139
+ ```
140
+
141
+ If you don't set them, defaults are blue (`#2563eb`).
142
+
143
+ ## What must NOT be done
144
+
145
+ - **Don't put the modal in every page** — put it in a single layout that wraps all pages. Multiple instances per page break (duplicate DOM ids).
146
+ - **Don't hardcode EmailJS keys in the component invocation.** Read from `codejitsu.config.ts` so rotating keys touches one file.
147
+ - **Don't put `secretKey` or sensitive EmailJS values in the config.** Only the public key is safe to ship to the browser. EmailJS Service ID + Template ID + Public Key are all public.
148
+ - **Don't disable the honeypot.** It's invisible to humans and catches a meaningful slice of bot submissions for free.
149
+ - **Don't add analytics calls inside the modal.** Use the `codejitsu-contact-submitted` event from outside.
150
+ - **Don't replace the focus trap or Esc handler with custom logic.** Both are accessibility-required.
151
+
152
+ ## Verify
153
+
154
+ - [ ] Modal opens when trigger clicked (any `data-codejitsu-contact-trigger`)
155
+ - [ ] Esc closes it
156
+ - [ ] Backdrop click closes it
157
+ - [ ] Tab cycles within the modal (focus trap)
158
+ - [ ] Required fields show `*` and HTML5 validation fires on empty submit
159
+ - [ ] Submit calls EmailJS and shows toast on success
160
+ - [ ] On submit success, `codejitsu-contact-submitted` event fires
161
+ - [ ] reCAPTCHA (if configured) blocks submit until completed
162
+ - [ ] Honeypot field is visually hidden (off-screen) and not focusable
163
+
164
+ Run `npx codejitsu audit` — the Forms group will detect the modal, count its fields, verify the JS submit hook is present.
@@ -0,0 +1,35 @@
1
+ # Contact module — checklist
2
+
3
+ ## Setup
4
+
5
+ - [ ] `codejitsu.config.ts` has a `contact.emailjs` block with serviceId, templateId, publicKey.
6
+ - [ ] (Optional) `contact.recaptcha.siteKey` set if site uses reCAPTCHA.
7
+ - [ ] EmailJS template variables match what the modal sends: `name`, `email`, `phone`, `message`.
8
+
9
+ ## Wiring
10
+
11
+ - [ ] Exactly **one** `<ContactModal>` per page (placed in a layout that wraps every page).
12
+ - [ ] At least one trigger exists with `data-codejitsu-contact-trigger`.
13
+ - [ ] (If site has GA4/Ads) A `codejitsu-contact-submitted` event listener fires the conversion.
14
+
15
+ ## Behaviour (manual or browser-tested)
16
+
17
+ - [ ] Modal opens when trigger clicked.
18
+ - [ ] Esc closes it.
19
+ - [ ] Backdrop click closes it.
20
+ - [ ] Tab cycles within the modal (focus trap).
21
+ - [ ] First field gets focus when opened.
22
+ - [ ] Required fields show `*` and HTML5 validation blocks empty submit.
23
+ - [ ] Submit shows "Sending…" state on the button.
24
+ - [ ] Successful submit shows the thank-you toast.
25
+ - [ ] Failed submit shows an error alert.
26
+
27
+ ## Spam protection
28
+
29
+ - [ ] Honeypot input present at `name="cj_hp_website"`, off-screen, `tabindex="-1"`.
30
+ - [ ] If reCAPTCHA configured: widget visible inside the form, submit blocked until solved.
31
+
32
+ ## Theming
33
+
34
+ - [ ] Site's `:root` CSS sets `--codejitsu-modal-accent` (otherwise it uses default blue).
35
+ - [ ] Modal's image (if used) is in `public/` and < 200KB.
@@ -0,0 +1,420 @@
1
+ ---
2
+ /**
3
+ * Codejitsu contact modal.
4
+ *
5
+ * Drop one instance into a layout that wraps every page (e.g. BaseLayout).
6
+ * Trigger from anywhere with:
7
+ *
8
+ * <button data-codejitsu-contact-trigger>Get a quote</button>
9
+ *
10
+ * On success: dispatches a `codejitsu-contact-submitted` custom event on
11
+ * `window`. Sites add their own listener for analytics (GA4 conversion,
12
+ * Google Ads, etc.) so this component stays generic.
13
+ *
14
+ * Theming via CSS variables (set on `:root` in your global CSS):
15
+ * --codejitsu-modal-accent (focus ring + button bg; default #2563eb)
16
+ * --codejitsu-modal-accent-hover (default #1d4ed8)
17
+ * --codejitsu-modal-on-accent (text on button; default #ffffff)
18
+ */
19
+
20
+ interface FieldProps {
21
+ enabled?: boolean;
22
+ required?: boolean;
23
+ label?: string;
24
+ placeholder?: string;
25
+ }
26
+
27
+ interface Props {
28
+ /** Unique id (lets multiple modals coexist if ever needed). Default 'codejitsu-contact'. */
29
+ id?: string;
30
+ title: string;
31
+ image?: {
32
+ src: string;
33
+ alt: string;
34
+ width?: number;
35
+ height?: number;
36
+ };
37
+ fields?: {
38
+ name?: FieldProps;
39
+ email?: FieldProps;
40
+ phone?: FieldProps;
41
+ message?: FieldProps;
42
+ };
43
+ submitText?: string;
44
+ thankYouMessage?: string;
45
+ emailjs: {
46
+ serviceId: string;
47
+ templateId: string;
48
+ publicKey: string;
49
+ };
50
+ recaptcha?: { siteKey: string };
51
+ }
52
+
53
+ const {
54
+ id = 'codejitsu-contact',
55
+ title,
56
+ image,
57
+ fields = {},
58
+ submitText = 'Send Message',
59
+ thankYouMessage = "Thanks! We'll get back to you shortly.",
60
+ emailjs,
61
+ recaptcha,
62
+ } = Astro.props as Props;
63
+
64
+ // Field defaults — all fields enabled, name+email+phone required, message optional.
65
+ const f = {
66
+ name: { enabled: true, required: true, label: 'Name', placeholder: 'Your full name', ...fields.name },
67
+ email: { enabled: true, required: true, label: 'Email', placeholder: 'you@email.com', ...fields.email },
68
+ phone: { enabled: true, required: true, label: 'Phone', placeholder: '(xxx) xxx-xxxx', ...fields.phone },
69
+ message: { enabled: true, required: false, label: 'Message', placeholder: 'How can we help?', ...fields.message },
70
+ };
71
+
72
+ const overlayId = `${id}-overlay`;
73
+ const cardId = `${id}-card`;
74
+ const closeId = `${id}-close`;
75
+ const formId = `${id}-form`;
76
+ const toastId = `${id}-toast`;
77
+ const titleId = `${id}-title`;
78
+
79
+ const config = {
80
+ serviceId: emailjs.serviceId,
81
+ templateId: emailjs.templateId,
82
+ publicKey: emailjs.publicKey,
83
+ recaptchaSiteKey: recaptcha?.siteKey ?? null,
84
+ };
85
+ ---
86
+
87
+ <div
88
+ id={overlayId}
89
+ class="cj-modal-overlay fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm opacity-0 pointer-events-none transition-opacity duration-300"
90
+ aria-hidden="true"
91
+ inert
92
+ role="dialog"
93
+ aria-modal="true"
94
+ aria-labelledby={titleId}
95
+ >
96
+ <div
97
+ id={cardId}
98
+ class="bg-white rounded-2xl shadow-2xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto p-6 sm:p-8 transform scale-95 transition-transform duration-300"
99
+ >
100
+ <div class="flex items-center justify-between mb-4">
101
+ <h2 id={titleId} class="text-xl font-bold text-gray-900">{title}</h2>
102
+ <button
103
+ type="button"
104
+ id={closeId}
105
+ class="text-gray-400 hover:text-gray-700 transition-colors p-1"
106
+ aria-label="Close modal"
107
+ >
108
+ <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
109
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
110
+ </svg>
111
+ </button>
112
+ </div>
113
+
114
+ <div class={image ? 'flex flex-col md:flex-row gap-6' : ''}>
115
+ {image && (
116
+ <div class="hidden md:block md:w-1/2 shrink-0">
117
+ <img
118
+ src={image.src}
119
+ alt={image.alt}
120
+ width={image.width ?? 600}
121
+ height={image.height ?? 600}
122
+ class="w-full h-full rounded-xl object-cover"
123
+ loading="lazy"
124
+ />
125
+ </div>
126
+ )}
127
+
128
+ <div class="flex-1">
129
+ <form id={formId} class="cj-emailjs-form space-y-4" novalidate>
130
+ {f.name.enabled && (
131
+ <div>
132
+ <label for={`${id}-name`} class="block text-sm font-medium text-gray-700 mb-1">
133
+ {f.name.label}{f.name.required && <span class="text-red-500"> *</span>}
134
+ </label>
135
+ <input
136
+ type="text"
137
+ id={`${id}-name`}
138
+ name="name"
139
+ required={f.name.required}
140
+ autocomplete="name"
141
+ class="cj-modal-input w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 outline-none transition-shadow"
142
+ placeholder={f.name.placeholder}
143
+ />
144
+ </div>
145
+ )}
146
+
147
+ {f.email.enabled && (
148
+ <div>
149
+ <label for={`${id}-email`} class="block text-sm font-medium text-gray-700 mb-1">
150
+ {f.email.label}{f.email.required && <span class="text-red-500"> *</span>}
151
+ </label>
152
+ <input
153
+ type="email"
154
+ id={`${id}-email`}
155
+ name="email"
156
+ required={f.email.required}
157
+ autocomplete="email"
158
+ class="cj-modal-input w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 outline-none transition-shadow"
159
+ placeholder={f.email.placeholder}
160
+ />
161
+ </div>
162
+ )}
163
+
164
+ {f.phone.enabled && (
165
+ <div>
166
+ <label for={`${id}-phone`} class="block text-sm font-medium text-gray-700 mb-1">
167
+ {f.phone.label}{f.phone.required && <span class="text-red-500"> *</span>}
168
+ </label>
169
+ <input
170
+ type="tel"
171
+ id={`${id}-phone`}
172
+ name="phone"
173
+ required={f.phone.required}
174
+ autocomplete="tel"
175
+ class="cj-modal-input w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 outline-none transition-shadow"
176
+ placeholder={f.phone.placeholder}
177
+ />
178
+ </div>
179
+ )}
180
+
181
+ {f.message.enabled && (
182
+ <div>
183
+ <label for={`${id}-message`} class="block text-sm font-medium text-gray-700 mb-1">
184
+ {f.message.label}{f.message.required && <span class="text-red-500"> *</span>}
185
+ </label>
186
+ <textarea
187
+ id={`${id}-message`}
188
+ name="message"
189
+ required={f.message.required}
190
+ rows="3"
191
+ class="cj-modal-input w-full border border-gray-300 rounded-lg px-4 py-2.5 text-gray-900 outline-none transition-shadow resize-y"
192
+ placeholder={f.message.placeholder}
193
+ ></textarea>
194
+ </div>
195
+ )}
196
+
197
+ {/* Honeypot — bots fill this; humans don't see it */}
198
+ <div aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;">
199
+ <label for={`${id}-website`}>Website (leave blank)</label>
200
+ <input type="text" id={`${id}-website`} name="cj_hp_website" tabindex="-1" autocomplete="off" />
201
+ </div>
202
+
203
+ {recaptcha && (
204
+ <div class="g-recaptcha" data-sitekey={recaptcha.siteKey}></div>
205
+ )}
206
+
207
+ <button
208
+ type="submit"
209
+ class="cj-modal-submit w-full text-center font-semibold py-3 rounded-lg transition-colors"
210
+ >
211
+ {submitText}
212
+ </button>
213
+ </form>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ <div
220
+ id={toastId}
221
+ class="cj-modal-toast fixed bottom-6 right-6 z-[110] bg-green-600 text-white px-6 py-3 rounded-lg shadow-lg transform translate-y-20 opacity-0 transition-all duration-300 pointer-events-none"
222
+ role="status"
223
+ aria-live="polite"
224
+ >
225
+ {thankYouMessage}
226
+ </div>
227
+
228
+ <style is:global>
229
+ :root {
230
+ --codejitsu-modal-accent: #2563eb;
231
+ --codejitsu-modal-accent-hover: #1d4ed8;
232
+ --codejitsu-modal-on-accent: #ffffff;
233
+ }
234
+ .cj-modal-input:focus {
235
+ border-color: var(--codejitsu-modal-accent);
236
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--codejitsu-modal-accent) 25%, transparent);
237
+ }
238
+ .cj-modal-submit {
239
+ background-color: var(--codejitsu-modal-accent);
240
+ color: var(--codejitsu-modal-on-accent);
241
+ }
242
+ .cj-modal-submit:hover:not(:disabled) {
243
+ background-color: var(--codejitsu-modal-accent-hover);
244
+ }
245
+ .cj-modal-submit:disabled {
246
+ opacity: 0.6;
247
+ cursor: not-allowed;
248
+ }
249
+ </style>
250
+
251
+ {recaptcha && (
252
+ <script is:inline src="https://www.google.com/recaptcha/api.js" async defer></script>
253
+ )}
254
+
255
+ <script is:inline src="https://cdn.jsdelivr.net/npm/@emailjs/browser@4/dist/email.min.js"></script>
256
+ <script is:inline define:vars={{ modalId: id, cfg: config }}>
257
+ // Per-modal IDs (one modal per page expected; this scopes to its DOM).
258
+ const overlay = document.getElementById(modalId + '-overlay');
259
+ const card = document.getElementById(modalId + '-card');
260
+ const closeBtn = document.getElementById(modalId + '-close');
261
+ const form = document.getElementById(modalId + '-form');
262
+ const toast = document.getElementById(modalId + '-toast');
263
+
264
+ if (!overlay || !card || !form) {
265
+ // Modal not present on this page; bail silently.
266
+ } else {
267
+ // Initialise EmailJS once per page load.
268
+ function initEmailJS() {
269
+ if (window.emailjs && !window.__cjEmailjsInitialized) {
270
+ window.emailjs.init({ publicKey: cfg.publicKey });
271
+ window.__cjEmailjsInitialized = true;
272
+ }
273
+ }
274
+ initEmailJS();
275
+ document.addEventListener('DOMContentLoaded', initEmailJS);
276
+
277
+ let previouslyFocused = null;
278
+
279
+ function getFocusable() {
280
+ return Array.from(card.querySelectorAll(
281
+ 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
282
+ ));
283
+ }
284
+
285
+ function open() {
286
+ previouslyFocused = document.activeElement;
287
+ overlay.classList.remove('opacity-0', 'pointer-events-none');
288
+ overlay.classList.add('opacity-100');
289
+ overlay.setAttribute('aria-hidden', 'false');
290
+ overlay.removeAttribute('inert');
291
+ card.classList.remove('scale-95');
292
+ card.classList.add('scale-100');
293
+ document.body.style.overflow = 'hidden';
294
+ setTimeout(function () {
295
+ const f = getFocusable();
296
+ if (f.length) f[0].focus();
297
+ }, 100);
298
+ }
299
+
300
+ function close() {
301
+ overlay.classList.add('opacity-0', 'pointer-events-none');
302
+ overlay.classList.remove('opacity-100');
303
+ overlay.setAttribute('aria-hidden', 'true');
304
+ overlay.setAttribute('inert', '');
305
+ card.classList.add('scale-95');
306
+ card.classList.remove('scale-100');
307
+ document.body.style.overflow = '';
308
+ if (previouslyFocused && previouslyFocused.focus) {
309
+ previouslyFocused.focus();
310
+ previouslyFocused = null;
311
+ }
312
+ }
313
+
314
+ function showToast() {
315
+ if (!toast) return;
316
+ toast.classList.remove('translate-y-20', 'opacity-0');
317
+ toast.classList.add('translate-y-0', 'opacity-100');
318
+ setTimeout(function () {
319
+ toast.classList.add('translate-y-20', 'opacity-0');
320
+ toast.classList.remove('translate-y-0', 'opacity-100');
321
+ }, 4000);
322
+ }
323
+
324
+ // Triggers: data-codejitsu-contact-trigger or data-codejitsu-contact-trigger="<id>".
325
+ document.addEventListener('click', function (e) {
326
+ const trigger = e.target.closest('[data-codejitsu-contact-trigger]');
327
+ if (trigger) {
328
+ const wanted = trigger.getAttribute('data-codejitsu-contact-trigger');
329
+ if (!wanted || wanted === modalId) {
330
+ e.preventDefault();
331
+ open();
332
+ }
333
+ return;
334
+ }
335
+ if (e.target.closest('#' + modalId + '-close')) {
336
+ close();
337
+ return;
338
+ }
339
+ if (e.target === overlay) close();
340
+ });
341
+
342
+ // Esc + focus trap.
343
+ document.addEventListener('keydown', function (e) {
344
+ if (overlay.classList.contains('pointer-events-none')) return;
345
+ if (e.key === 'Escape') { close(); return; }
346
+ if (e.key === 'Tab') {
347
+ const f = getFocusable();
348
+ if (!f.length) return;
349
+ const first = f[0], last = f[f.length - 1];
350
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
351
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
352
+ }
353
+ });
354
+
355
+ // Submit handler.
356
+ form.addEventListener('submit', function (e) {
357
+ e.preventDefault();
358
+
359
+ // Honeypot
360
+ const hp = form.querySelector('input[name="cj_hp_website"]');
361
+ if (hp && hp.value) {
362
+ // Bot detected — pretend success, don't actually send.
363
+ form.reset();
364
+ close();
365
+ showToast();
366
+ return;
367
+ }
368
+
369
+ if (!form.checkValidity()) {
370
+ form.reportValidity();
371
+ return;
372
+ }
373
+
374
+ if (cfg.recaptchaSiteKey) {
375
+ const captchaResponse = form.querySelector('textarea[name="g-recaptcha-response"]');
376
+ if (!captchaResponse || !captchaResponse.value) {
377
+ alert('Please complete the reCAPTCHA before submitting.');
378
+ return;
379
+ }
380
+ }
381
+
382
+ initEmailJS();
383
+ if (!window.emailjs) {
384
+ console.error('[codejitsu-contact] EmailJS SDK not loaded');
385
+ return;
386
+ }
387
+
388
+ const submitBtn = form.querySelector('button[type="submit"]');
389
+ const originalText = submitBtn ? submitBtn.textContent : '';
390
+ if (submitBtn) {
391
+ submitBtn.disabled = true;
392
+ submitBtn.textContent = 'Sending…';
393
+ }
394
+
395
+ window.emailjs.sendForm(cfg.serviceId, cfg.templateId, form).then(function () {
396
+ // Fire custom event for site-side analytics / conversion tracking.
397
+ window.dispatchEvent(new CustomEvent('codejitsu-contact-submitted', {
398
+ detail: {
399
+ modalId,
400
+ formData: Object.fromEntries(new FormData(form).entries()),
401
+ },
402
+ }));
403
+ form.reset();
404
+ close();
405
+ showToast();
406
+ }).catch(function (err) {
407
+ console.error('[codejitsu-contact] EmailJS send failed', err);
408
+ alert('Sorry, something went wrong. Please call us or try again.');
409
+ }).finally(function () {
410
+ if (submitBtn) {
411
+ submitBtn.disabled = false;
412
+ submitBtn.textContent = originalText;
413
+ }
414
+ if (window.grecaptcha && typeof window.grecaptcha.reset === 'function') {
415
+ try { window.grecaptcha.reset(); } catch (e) {}
416
+ }
417
+ });
418
+ });
419
+ }
420
+ </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibalzam/codejitsu-core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Shared core for Codejitsu Astro sites — reusable code and Claude-facing instructions for blog, SEO, images, deploy, and llms.txt.",
6
6
  "keywords": [
@@ -16,13 +16,13 @@
16
16
  ],
17
17
  "license": "MIT",
18
18
  "author": "Ika Balzam <ika@codejitsu.ca>",
19
- "homepage": "https://github.com/ibalzam/codejitsu-site-kit#readme",
19
+ "homepage": "https://github.com/ikanc/codejitsu-site-kit#readme",
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "git+https://github.com/ibalzam/codejitsu-site-kit.git"
22
+ "url": "git+https://github.com/ikanc/codejitsu-site-kit.git"
23
23
  },
24
24
  "bugs": {
25
- "url": "https://github.com/ibalzam/codejitsu-site-kit/issues"
25
+ "url": "https://github.com/ikanc/codejitsu-site-kit/issues"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=20"
@@ -60,6 +60,7 @@
60
60
  "default": "./modules/seo/src/sitemap.js"
61
61
  },
62
62
  "./seo/Head.astro": "./modules/seo/templates/Head.astro",
63
+ "./contact/ContactModal.astro": "./modules/contact/templates/ContactModal.astro",
63
64
  "./rehype/trailing-slash": "./modules/rehype/src/trailing-slash.mjs",
64
65
  "./images": {
65
66
  "types": "./modules/images/src/index.d.ts",