Plan Your Visit landing page for visit.waterschurch.org
Next.js 16 static export + PHP 8 API for cPanel/Apache hosting. - Location-aware campus finder (IP estimate, optional precise location, ZIP search) - Three-step Plan Your Visit form with separate email/SMS consent - PHP API: validation, anti-spam, rate limiting, SQLite lead store, Mailchimp and Sinch Engage adapters, idempotent retries via cron - Attribution capture (utm_*, gclid/gbraid/wbraid, fbclid, ttclid, twclid) and consent-aware analytics hooks - Vitest and PHP test suites, cPanel deployment script and docs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Generates optimized, responsive images from assets/source into public/images.
|
||||
* Runs automatically before `next build`. Safe to re-run; skips up-to-date files.
|
||||
*
|
||||
* npm run images
|
||||
*/
|
||||
import sharp from "sharp";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SRC = path.join(ROOT, "assets/source");
|
||||
const OUT = path.join(ROOT, "public/images");
|
||||
|
||||
/** name → { file, widths, quality } */
|
||||
const PHOTOS = {
|
||||
"hero-worship": { file: "photos/hero-north-attleboro-worship.jpg", widths: [640, 1024, 1600, 2000], quality: 72 },
|
||||
"campus-north-attleboro": { file: "photos/campus-north-attleboro.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-norwood": { file: "photos/campus-norwood.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-woonsocket": { file: "photos/campus-woonsocket.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-southcoast": { file: "photos/campus-southcoast.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-apollo-beach": { file: "photos/campus-apollo-beach.jpg", widths: [480, 960], quality: 74 },
|
||||
"campus-orlando": { file: "photos/campus-orlando.jpg", widths: [480, 960], quality: 74 },
|
||||
"worship-southcoast": { file: "photos/worship-southcoast.jpg", widths: [640, 1200], quality: 72 },
|
||||
"community-1": { file: "photos/community-1.jpg", widths: [640, 1200], quality: 74 },
|
||||
"save-a-seat": { file: "photos/save-a-seat.jpg", widths: [640, 1200], quality: 74 },
|
||||
};
|
||||
|
||||
const LOGOS = {
|
||||
"waters-mark-teal": { file: "logos/waters-mark-teal.png", widths: [96, 192, 512] },
|
||||
};
|
||||
|
||||
const SVGS = ["logos/waters-mark-white.svg", "logos/waters-church-wordmark-white.svg"];
|
||||
|
||||
async function newer(src, out) {
|
||||
try {
|
||||
const [a, b] = await Promise.all([fs.stat(src), fs.stat(out)]);
|
||||
return a.mtimeMs > b.mtimeMs;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT, { recursive: true });
|
||||
const manifest = {};
|
||||
|
||||
for (const [name, spec] of Object.entries(PHOTOS)) {
|
||||
const src = path.join(SRC, spec.file);
|
||||
const meta = await sharp(src).metadata();
|
||||
manifest[name] = { width: meta.width, height: meta.height, widths: [], formats: ["avif", "webp", "jpg"] };
|
||||
for (const w of spec.widths) {
|
||||
if (w > meta.width) continue;
|
||||
manifest[name].widths.push(w);
|
||||
const base = path.join(OUT, `${name}-${w}`);
|
||||
const resized = () => sharp(src).resize({ width: w, withoutEnlargement: true });
|
||||
if (await newer(src, `${base}.webp`)) await resized().webp({ quality: spec.quality }).toFile(`${base}.webp`);
|
||||
if (await newer(src, `${base}.avif`)) await resized().avif({ quality: Math.max(40, spec.quality - 20) }).toFile(`${base}.avif`);
|
||||
if (await newer(src, `${base}.jpg`)) await resized().jpeg({ quality: spec.quality + 4, mozjpeg: true }).toFile(`${base}.jpg`);
|
||||
}
|
||||
// tiny blurred placeholder to avoid layout flash
|
||||
const { data } = await sharp(src).resize(24).webp({ quality: 30 }).toBuffer({ resolveWithObject: true });
|
||||
manifest[name].blur = `data:image/webp;base64,${data.toString("base64")}`;
|
||||
}
|
||||
|
||||
for (const [name, spec] of Object.entries(LOGOS)) {
|
||||
const src = path.join(SRC, spec.file);
|
||||
for (const w of spec.widths) {
|
||||
const out = path.join(OUT, `${name}-${w}.png`);
|
||||
if (await newer(src, out)) await sharp(src).resize({ width: w }).png({ compressionLevel: 9 }).toFile(out);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of SVGS) {
|
||||
const out = path.join(OUT, path.basename(rel));
|
||||
if (await newer(path.join(SRC, rel), out)) await fs.copyFile(path.join(SRC, rel), out);
|
||||
}
|
||||
|
||||
// Favicons / social image
|
||||
const mark = path.join(SRC, LOGOS["waters-mark-teal"].file);
|
||||
await sharp(mark).resize(32).png().toFile(path.join(OUT, "favicon-32.png"));
|
||||
await sharp(mark).resize(180).png().toFile(path.join(OUT, "apple-touch-icon.png"));
|
||||
await sharp(mark).resize(512).png().toFile(path.join(OUT, "icon-512.png"));
|
||||
const og = path.join(OUT, "og-image.jpg");
|
||||
if (await newer(path.join(SRC, PHOTOS["hero-worship"].file), og)) {
|
||||
await sharp(path.join(SRC, PHOTOS["hero-worship"].file))
|
||||
.resize(1200, 630, { fit: "cover", position: "centre" })
|
||||
.jpeg({ quality: 78, mozjpeg: true })
|
||||
.toFile(og);
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(ROOT, "src/config/images.manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
const files = await fs.readdir(OUT);
|
||||
let total = 0;
|
||||
for (const f of files) total += (await fs.stat(path.join(OUT, f))).size;
|
||||
console.log(`images: ${files.length} files, ${(total / 1024).toFixed(0)} KB total in public/images`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Creates dist/visit-waterschurch-<version>-<date>.zip from ./out for upload
|
||||
* through cPanel File Manager (Upload → Extract). Also copies private/.env.example
|
||||
* next to it as a reminder of what must be configured server-side.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const OUT = path.join(ROOT, "out");
|
||||
const DIST = path.join(ROOT, "dist");
|
||||
|
||||
const pkg = JSON.parse(await fs.readFile(path.join(ROOT, "package.json"), "utf8"));
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
await fs.mkdir(DIST, { recursive: true });
|
||||
const zip = path.join(DIST, `visit-waterschurch-${pkg.version}-${stamp}.zip`);
|
||||
await fs.rm(zip, { force: true });
|
||||
// -X strips extended attributes; -r recursive; . includes dot-files (.htaccess)
|
||||
execFileSync("zip", ["-X", "-r", zip, "."], { cwd: OUT, stdio: "inherit" });
|
||||
await fs.copyFile(path.join(ROOT, ".env.example"), path.join(DIST, "env.example.txt"));
|
||||
const size = (await fs.stat(zip)).size;
|
||||
console.log(`package: ${path.relative(ROOT, zip)} (${(size / 1024 / 1024).toFixed(1)} MB)`);
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Post-build: make ./out a complete, uploadable cPanel document root.
|
||||
* - copy deploy/htaccess.root → out/.htaccess
|
||||
* - copy config/campuses.json → out/api/config/campuses.json (read by PHP)
|
||||
* - ensure the private lib/cron folders keep their .htaccess deny rules
|
||||
* - write out/BUILD.txt with the build id
|
||||
*/
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const OUT = path.join(ROOT, "out");
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!(await exists(OUT))) throw new Error("out/ not found — run `next build` first");
|
||||
|
||||
await fs.copyFile(path.join(ROOT, "deploy/htaccess.root"), path.join(OUT, ".htaccess"));
|
||||
|
||||
await fs.mkdir(path.join(OUT, "api/config"), { recursive: true });
|
||||
await fs.copyFile(path.join(ROOT, "config/campuses.json"), path.join(OUT, "api/config/campuses.json"));
|
||||
await fs.writeFile(path.join(OUT, "api/config/.htaccess"), "Require all denied\n");
|
||||
|
||||
for (const dir of ["api/lib", "api/cron"]) {
|
||||
const ht = path.join(OUT, dir, ".htaccess");
|
||||
if (!(await exists(ht))) await fs.writeFile(ht, "Require all denied\n");
|
||||
}
|
||||
|
||||
// Next writes 404.html for the not-found route; make sure it exists for ErrorDocument
|
||||
if (!(await exists(path.join(OUT, "404.html")))) {
|
||||
console.warn("warning: out/404.html missing — custom 404 will not work");
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(await fs.readFile(path.join(ROOT, "package.json"), "utf8"));
|
||||
await fs.writeFile(path.join(OUT, "BUILD.txt"), `${pkg.name} ${pkg.version}\nbuilt ${new Date().toISOString()}\n`);
|
||||
|
||||
// Report
|
||||
let files = 0;
|
||||
let bytes = 0;
|
||||
async function walk(d) {
|
||||
for (const e of await fs.readdir(d, { withFileTypes: true })) {
|
||||
const p = path.join(d, e.name);
|
||||
if (e.isDirectory()) await walk(p);
|
||||
else {
|
||||
files++;
|
||||
bytes += (await fs.stat(p)).size;
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(OUT);
|
||||
console.log(`postbuild: out/ ready — ${files} files, ${(bytes / 1024 / 1024).toFixed(1)} MB`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user