@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.
- package/modules/audit/src/groups/forms.mjs +10 -0
- package/modules/config/src/types.d.ts +16 -0
- package/modules/config/src/types.ts +17 -0
- package/modules/contact/CLAUDE.md +164 -0
- package/modules/contact/checklist.md +35 -0
- package/modules/contact/templates/ContactModal.astro +420 -0
- package/package.json +5 -4
|
@@ -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.
|
|
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/
|
|
19
|
+
"homepage": "https://github.com/ikanc/codejitsu-site-kit#readme",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/
|
|
22
|
+
"url": "git+https://github.com/ikanc/codejitsu-site-kit.git"
|
|
23
23
|
},
|
|
24
24
|
"bugs": {
|
|
25
|
-
"url": "https://github.com/
|
|
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",
|