@ibalzam/codejitsu-core 0.5.0 → 0.7.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/bin/codejitsu.mjs +4 -0
- package/modules/audit/src/groups/forms.mjs +10 -0
- package/modules/audit/src/groups/structure.mjs +60 -1
- package/modules/cli/src/deploy.mjs +210 -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
package/bin/codejitsu.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { runBlog } from '../modules/cli/src/blog.mjs';
|
|
4
|
+
import { runDeploySetup } from '../modules/cli/src/deploy.mjs';
|
|
4
5
|
import { runAudit } from '../modules/audit/src/run.mjs';
|
|
5
6
|
|
|
6
7
|
const subcommand = process.argv[2];
|
|
@@ -9,6 +10,7 @@ const rest = process.argv.slice(3);
|
|
|
9
10
|
const COMMANDS = {
|
|
10
11
|
'blog:list': () => runBlog('blog:list'),
|
|
11
12
|
'blog:drafts': () => runBlog('blog:drafts'),
|
|
13
|
+
'deploy:setup': () => runDeploySetup(),
|
|
12
14
|
audit: () => {
|
|
13
15
|
const { values } = parseArgs({
|
|
14
16
|
args: rest,
|
|
@@ -52,6 +54,8 @@ function printHelp() {
|
|
|
52
54
|
console.log(` blog:list List every non-draft post with URL + image check`);
|
|
53
55
|
console.log(` blog:drafts List future-dated (pending) posts only`);
|
|
54
56
|
console.log(``);
|
|
57
|
+
console.log(` deploy:setup Wire up daily Cloudflare deploy (prompts for hook URL)`);
|
|
58
|
+
console.log(``);
|
|
55
59
|
console.log(` audit Run pre-delivery audit. Flags:`);
|
|
56
60
|
console.log(` --live <url> Add live-URL checks (SSL, headers, 404, broken links)`);
|
|
57
61
|
console.log(` --a11y Add axe-core WCAG 2.1 AA scan (with --live)`);
|
|
@@ -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);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
3
4
|
import { pass, fail, warn, info } from '../util.mjs';
|
|
4
5
|
|
|
5
6
|
export async function runStructure(ctx) {
|
|
@@ -51,11 +52,40 @@ export async function runStructure(ctx) {
|
|
|
51
52
|
? pass('wrangler.toml at site root')
|
|
52
53
|
: fail('wrangler.toml missing')
|
|
53
54
|
);
|
|
55
|
+
const workflowExists = fs.existsSync(path.join(cwd, '.github/workflows/daily-deploy.yml'));
|
|
54
56
|
results.push(
|
|
55
|
-
|
|
57
|
+
workflowExists
|
|
56
58
|
? pass('Daily deploy workflow present')
|
|
57
59
|
: warn('No .github/workflows/daily-deploy.yml')
|
|
58
60
|
);
|
|
61
|
+
|
|
62
|
+
// Daily deploy needs CLOUDFLARE_DEPLOY_HOOK_URL set as a GH secret.
|
|
63
|
+
// We can only verify this via `gh secret list`. Skip silently if gh is
|
|
64
|
+
// unavailable; only run when the workflow file exists (otherwise the
|
|
65
|
+
// secret is moot).
|
|
66
|
+
if (workflowExists) {
|
|
67
|
+
const repo = detectGitHubRepo(cwd);
|
|
68
|
+
if (!repo) {
|
|
69
|
+
results.push(info('Skipped GH secret check (no GitHub remote detected)'));
|
|
70
|
+
} else {
|
|
71
|
+
const ghAvailable = await commandExists('gh');
|
|
72
|
+
if (!ghAvailable) {
|
|
73
|
+
results.push(info('Skipped GH secret check (gh CLI not installed)'));
|
|
74
|
+
} else {
|
|
75
|
+
const secrets = await listSecrets(repo);
|
|
76
|
+
if (secrets === null) {
|
|
77
|
+
results.push(info('Skipped GH secret check (gh not authenticated for this repo)'));
|
|
78
|
+
} else if (secrets.includes('CLOUDFLARE_DEPLOY_HOOK_URL')) {
|
|
79
|
+
results.push(pass(`CLOUDFLARE_DEPLOY_HOOK_URL set in ${repo}`));
|
|
80
|
+
} else {
|
|
81
|
+
results.push(warn(
|
|
82
|
+
'CLOUDFLARE_DEPLOY_HOOK_URL secret missing',
|
|
83
|
+
'Daily deploy workflow will fail. Run `npx codejitsu deploy:setup` to configure.'
|
|
84
|
+
));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
// Astro config sanity + trailing-slash plugin agreement
|
|
@@ -103,3 +133,32 @@ export async function runStructure(ctx) {
|
|
|
103
133
|
|
|
104
134
|
return results;
|
|
105
135
|
}
|
|
136
|
+
|
|
137
|
+
function detectGitHubRepo(cwd) {
|
|
138
|
+
const cfgPath = path.join(cwd, '.git/config');
|
|
139
|
+
if (!fs.existsSync(cfgPath)) return null;
|
|
140
|
+
const cfg = fs.readFileSync(cfgPath, 'utf8');
|
|
141
|
+
const m = cfg.match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/m);
|
|
142
|
+
return m ? m[1] : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function commandExists(cmd) {
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const proc = spawn('which', [cmd], { stdio: 'ignore' });
|
|
148
|
+
proc.on('close', (code) => resolve(code === 0));
|
|
149
|
+
proc.on('error', () => resolve(false));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function listSecrets(repo) {
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
const proc = spawn('gh', ['secret', 'list', '--repo', repo], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
156
|
+
let stdout = '';
|
|
157
|
+
proc.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
158
|
+
proc.on('close', (code) => {
|
|
159
|
+
if (code !== 0) return resolve(null);
|
|
160
|
+
resolve(stdout.split('\n').map((l) => l.split(/\s+/)[0]).filter(Boolean));
|
|
161
|
+
});
|
|
162
|
+
proc.on('error', () => resolve(null));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import readline from 'readline';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
5
|
+
import { loadConfig, isModuleEnabled } from '../../config/src/load.mjs';
|
|
6
|
+
import { c } from './format.mjs';
|
|
7
|
+
|
|
8
|
+
const PACKAGE_ROOT = path.resolve(
|
|
9
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
10
|
+
'..', '..', '..'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `codejitsu deploy:setup` — interactive wizard for Cloudflare Pages + daily
|
|
15
|
+
* deploy hook. Copies the workflow + wrangler templates, prompts for the
|
|
16
|
+
* Cloudflare deploy hook URL, and stores it as a GitHub Actions secret.
|
|
17
|
+
*
|
|
18
|
+
* Idempotent: re-running with everything already in place is a no-op (just
|
|
19
|
+
* verifies state).
|
|
20
|
+
*/
|
|
21
|
+
export async function runDeploySetup() {
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
|
|
24
|
+
let config;
|
|
25
|
+
try { config = await loadConfig(cwd); }
|
|
26
|
+
catch (err) {
|
|
27
|
+
console.error(c.red('✗ No codejitsu.config found in ' + cwd));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!isModuleEnabled(config, 'deploy')) {
|
|
32
|
+
console.error(c.red('✗ deploy module is disabled in codejitsu.config'));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(c.bold('\nCodejitsu Deploy Setup\n'));
|
|
37
|
+
|
|
38
|
+
// ─── Detect environment ──────────────────────────────────────────────
|
|
39
|
+
if (!fs.existsSync(path.join(cwd, '.git'))) {
|
|
40
|
+
console.error(c.red('✗ Not a git repository. Initialise git first.'));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const repo = detectGitHubRepo(cwd);
|
|
45
|
+
if (!repo) {
|
|
46
|
+
console.error(c.red('✗ Could not detect GitHub repo from git remote.'));
|
|
47
|
+
console.error(' Set up: git remote add origin git@github.com:owner/name.git');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
console.log(c.green('✓') + ` GitHub repo: ${c.bold(repo)}`);
|
|
51
|
+
|
|
52
|
+
const ghAvailable = await commandExists('gh');
|
|
53
|
+
if (!ghAvailable) {
|
|
54
|
+
console.error(c.red('✗ `gh` CLI not in PATH.'));
|
|
55
|
+
console.error(' Install: https://cli.github.com/');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
const ghAuthed = await ghIsAuthed();
|
|
59
|
+
if (!ghAuthed) {
|
|
60
|
+
console.error(c.red('✗ `gh` not authenticated.'));
|
|
61
|
+
console.error(' Run: gh auth login');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
console.log(c.green('✓') + ' gh CLI authenticated');
|
|
65
|
+
|
|
66
|
+
const pagesName = config.deploy?.cloudflarePagesName;
|
|
67
|
+
if (pagesName) console.log(c.green('✓') + ` Cloudflare Pages name: ${c.bold(pagesName)}`);
|
|
68
|
+
else console.log(c.yellow('!') + ' No deploy.cloudflarePagesName in codejitsu.config (wrangler.toml will need a manual name)');
|
|
69
|
+
|
|
70
|
+
// ─── Workflow + wrangler files ───────────────────────────────────────
|
|
71
|
+
console.log('\nChecking files…');
|
|
72
|
+
|
|
73
|
+
const workflowDest = path.join(cwd, '.github/workflows/daily-deploy.yml');
|
|
74
|
+
const wranglerDest = path.join(cwd, 'wrangler.toml');
|
|
75
|
+
|
|
76
|
+
if (fs.existsSync(workflowDest)) {
|
|
77
|
+
console.log(c.green('✓') + ' .github/workflows/daily-deploy.yml exists');
|
|
78
|
+
} else {
|
|
79
|
+
const workflowSrc = path.join(PACKAGE_ROOT, 'modules/deploy/templates/daily-deploy.yml');
|
|
80
|
+
fs.mkdirSync(path.dirname(workflowDest), { recursive: true });
|
|
81
|
+
fs.copyFileSync(workflowSrc, workflowDest);
|
|
82
|
+
console.log(c.green('+') + ' Created .github/workflows/daily-deploy.yml');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (fs.existsSync(wranglerDest)) {
|
|
86
|
+
console.log(c.green('✓') + ' wrangler.toml exists');
|
|
87
|
+
} else {
|
|
88
|
+
const wranglerSrc = path.join(PACKAGE_ROOT, 'modules/deploy/templates/wrangler.toml');
|
|
89
|
+
let contents = fs.readFileSync(wranglerSrc, 'utf8');
|
|
90
|
+
if (pagesName) contents = contents.replace('TODO-site-name', pagesName);
|
|
91
|
+
fs.writeFileSync(wranglerDest, contents);
|
|
92
|
+
console.log(c.green('+') + ' Created wrangler.toml' + (pagesName ? '' : ' (edit `name` field)'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── GitHub secret ───────────────────────────────────────────────────
|
|
96
|
+
console.log('\nChecking GitHub Actions secrets…');
|
|
97
|
+
const secrets = await listSecrets(repo);
|
|
98
|
+
const hasSecret = secrets.includes('CLOUDFLARE_DEPLOY_HOOK_URL');
|
|
99
|
+
|
|
100
|
+
if (hasSecret) {
|
|
101
|
+
console.log(c.green('✓') + ' CLOUDFLARE_DEPLOY_HOOK_URL is set');
|
|
102
|
+
const rotate = await prompt('\nRotate the deploy hook URL? [y/N]: ');
|
|
103
|
+
if (!/^y(es)?$/i.test(rotate.trim())) {
|
|
104
|
+
console.log(c.gray('\nNothing to do. The daily deploy is configured.\n'));
|
|
105
|
+
await offerTestRun(repo);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Prompt for URL.
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log('Get a deploy hook URL from Cloudflare Pages:');
|
|
113
|
+
console.log(c.gray(' 1. Open Cloudflare dashboard → Pages → ' + (pagesName ?? 'your project') + ' → Settings'));
|
|
114
|
+
console.log(c.gray(' 2. Builds & deployments → Deploy hooks → "Add deploy hook"'));
|
|
115
|
+
console.log(c.gray(' 3. Name: "daily-scheduled-content" — Branch: main'));
|
|
116
|
+
console.log(c.gray(' 4. Copy the URL'));
|
|
117
|
+
console.log('');
|
|
118
|
+
|
|
119
|
+
const url = (await prompt('Paste the deploy hook URL: ')).trim();
|
|
120
|
+
if (!url.startsWith('https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/')) {
|
|
121
|
+
console.error(c.red('\n✗ That doesn\'t look like a Cloudflare Pages deploy hook URL.'));
|
|
122
|
+
console.error(' Expected prefix: https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/');
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const setOk = await setSecret(repo, 'CLOUDFLARE_DEPLOY_HOOK_URL', url);
|
|
127
|
+
if (!setOk) {
|
|
128
|
+
console.error(c.red('\n✗ Failed to set GitHub secret.'));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
console.log(c.green('✓') + ` Secret CLOUDFLARE_DEPLOY_HOOK_URL set in ${repo}`);
|
|
132
|
+
|
|
133
|
+
await offerTestRun(repo);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function offerTestRun(repo) {
|
|
137
|
+
const test = await prompt('\nTrigger the workflow once now to test? [y/N]: ');
|
|
138
|
+
if (!/^y(es)?$/i.test(test.trim())) {
|
|
139
|
+
console.log(c.gray('\nDone. Daily deploy fires at 13:00 UTC (06:00 PDT / 05:00 PST).\n'));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const result = await runGh(['workflow', 'run', 'Daily Deploy', '--repo', repo]);
|
|
143
|
+
if (result.code === 0) {
|
|
144
|
+
console.log(c.green('✓') + ' Workflow "Daily Deploy" triggered');
|
|
145
|
+
console.log(c.gray(' Watch: ') + c.gray(`https://github.com/${repo}/actions`));
|
|
146
|
+
} else {
|
|
147
|
+
console.error(c.yellow('!') + ' Could not trigger workflow: ' + result.stderr.trim());
|
|
148
|
+
console.error(' You can trigger it manually from the GitHub Actions tab.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function detectGitHubRepo(cwd) {
|
|
155
|
+
const cfgPath = path.join(cwd, '.git/config');
|
|
156
|
+
if (!fs.existsSync(cfgPath)) return null;
|
|
157
|
+
const cfg = fs.readFileSync(cfgPath, 'utf8');
|
|
158
|
+
// Match SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
|
|
159
|
+
const m = cfg.match(/github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/m);
|
|
160
|
+
return m ? m[1] : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function commandExists(cmd) {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
const proc = spawn('which', [cmd], { stdio: 'ignore' });
|
|
166
|
+
proc.on('close', (code) => resolve(code === 0));
|
|
167
|
+
proc.on('error', () => resolve(false));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function ghIsAuthed() {
|
|
172
|
+
const r = await runGh(['auth', 'status']);
|
|
173
|
+
return r.code === 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function listSecrets(repo) {
|
|
177
|
+
const r = await runGh(['secret', 'list', '--repo', repo]);
|
|
178
|
+
if (r.code !== 0) return [];
|
|
179
|
+
return r.stdout
|
|
180
|
+
.split('\n')
|
|
181
|
+
.map((line) => line.split(/\s+/)[0])
|
|
182
|
+
.filter(Boolean);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function setSecret(repo, name, value) {
|
|
186
|
+
const r = await runGh(['secret', 'set', name, '--repo', repo, '--body', value]);
|
|
187
|
+
return r.code === 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function runGh(args) {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
const proc = spawn('gh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
193
|
+
let stdout = '';
|
|
194
|
+
let stderr = '';
|
|
195
|
+
proc.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
196
|
+
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
197
|
+
proc.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
198
|
+
proc.on('error', (err) => resolve({ code: 1, stdout: '', stderr: err.message }));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function prompt(question) {
|
|
203
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
rl.question(question, (answer) => {
|
|
206
|
+
rl.close();
|
|
207
|
+
resolve(answer);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -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.7.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",
|