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:
Waters Church
2026-09-22 13:22:26 -04:00
co-authored by Claude Fable 5.1
commit 78cd0cc550
109 changed files with 16861 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# =============================================================================
# Deploy visit.waterschurch.org to the cPanel host over SSH.
#
# deploy/deploy.sh # build + upload + configure (goes live)
# deploy/deploy.sh --no-build # upload the existing ./out
# deploy/deploy.sh --keep-redirect # deploy but keep sending visitors to
# # the Church Center form (pre-launch)
#
# Requires: the SSH alias "waterschurch" from ~/.ssh/config, rsync, node, npm.
# Idempotent: safe to re-run for every content update.
#
# On the server (as the cPanel user) it:
# 1. ensures the subdomain exists with document root ~/visit.waterschurch.org
# 2. creates ~/visit-private/{data,logs} and a .env from .env.example if missing
# 3. rsyncs ./out → ~/visit.waterschurch.org, preserving cPanel's own files
# (.user.ini, php.ini, .well-known/) and rebuilding .htaccess from:
# cPanel-generated PHP blocks + [pre-launch redirect] + deploy/htaccess.root
# 4. installs the 5-minute retry cron job if absent
# 5. requests an AutoSSL check and prints the health endpoint
# It never touches DNS, the main site, or an existing .env.
# =============================================================================
set -euo pipefail
SSH_HOST="${SSH_HOST:-waterschurch}"
SUBDOMAIN="visit"
ROOTDOMAIN="waterschurch.org"
FQDN="$SUBDOMAIN.$ROOTDOMAIN"
REMOTE_DOCROOT="visit.waterschurch.org" # relative to $HOME on the server
REMOTE_PRIVATE="visit-private" # relative to $HOME on the server
BUILD=1
KEEP_REDIRECT=0
for arg in "$@"; do
case "$arg" in
--no-build) BUILD=0 ;;
--keep-redirect) KEEP_REDIRECT=1 ;;
*) echo "unknown option: $arg"; exit 2 ;;
esac
done
cd "$(dirname "$0")/.."
if [[ $BUILD -eq 1 ]]; then
echo "▶ building"
npm run build
fi
[[ -f out/index.html && -f out/.htaccess && -f out/api/plan-visit.php ]] || { echo "out/ is incomplete — run npm run build"; exit 1; }
echo "▶ preparing server"
ssh "$SSH_HOST" bash -s -- "$SUBDOMAIN" "$ROOTDOMAIN" "$REMOTE_DOCROOT" "$REMOTE_PRIVATE" <<'REMOTE'
set -euo pipefail
SUB="$1"; ROOT="$2"; DOCROOT="$HOME/$3"; PRIV="$HOME/$4"; FQDN="$SUB.$ROOT"
if uapi --output=json DomainInfo list_domains 2>/dev/null | grep -q "\"$FQDN\""; then
CUR=$(uapi --output=json DomainInfo single_domain_data domain="$FQDN" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["data"].get("documentroot",""))' 2>/dev/null || true)
echo " subdomain $FQDN exists (docroot: ${CUR:-unknown})"
if [[ -n "$CUR" && "$CUR" != "$DOCROOT" ]]; then
echo " ⚠ document root differs — pointing it at $DOCROOT"
uapi --output=json SubDomain changedocroot subdomain="$SUB" rootdomain="$ROOT" dir="$DOCROOT" >/dev/null
fi
else
echo " creating subdomain $FQDN → $DOCROOT"
uapi --output=json SubDomain addsubdomain domain="$SUB" rootdomain="$ROOT" dir="$DOCROOT" | python3 -c 'import json,sys; r=json.load(sys.stdin)["result"]; print(" ", "ok" if r["status"] else r["errors"])'
fi
mkdir -p "$DOCROOT"
# Preserve cPanel-generated .htaccess blocks (PHP ini directives + PHP handler) before we replace the file.
if [[ -f "$DOCROOT/.htaccess" ]]; then
awk '/^# BEGIN cPanel-generated/,/^# END cPanel-generated/ {print} /^# php -- BEGIN cPanel-generated/,/^# php -- END cPanel-generated/ {print}' "$DOCROOT/.htaccess" > "$DOCROOT/.htaccess.cpanel-blocks"
echo " preserved $(grep -c . "$DOCROOT/.htaccess.cpanel-blocks") lines of cPanel-generated .htaccess directives"
fi
mkdir -p "$PRIV/data" "$PRIV/logs"
chmod 750 "$PRIV" "$PRIV/data" "$PRIV/logs"
echo " private dir: $PRIV"
REMOTE
echo "▶ uploading build"
rsync -az --delete \
--exclude '/.htaccess' --exclude '/.htaccess.*' \
--exclude '/.user.ini' --exclude '/php.ini' --exclude '/.well-known/' \
--exclude 'api/data/' \
--chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r \
out/ "$SSH_HOST:$REMOTE_DOCROOT/"
scp -q out/.htaccess "$SSH_HOST:$REMOTE_DOCROOT/.htaccess.visit"
scp -q deploy/htaccess.prelaunch-redirect "$SSH_HOST:$REMOTE_DOCROOT/.htaccess.prelaunch"
scp -q .env.example "$SSH_HOST:$REMOTE_PRIVATE/.env.example"
echo "▶ configuring"
ssh "$SSH_HOST" bash -s -- "$REMOTE_DOCROOT" "$REMOTE_PRIVATE" "$FQDN" "$KEEP_REDIRECT" <<'REMOTE'
set -euo pipefail
DOCROOT="$HOME/$1"; PRIV="$HOME/$2"; FQDN="$3"; KEEP="$4"
# --- .env (never overwritten)
if [[ ! -f "$PRIV/.env" ]]; then
cp "$PRIV/.env.example" "$PRIV/.env"
SALT=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
sed -i "s|^HASH_SALT=.*|HASH_SALT=$SALT|; s|^ALLOWED_ORIGINS=.*|ALLOWED_ORIGINS=|; s|^DEBUG_GEO=.*|DEBUG_GEO=false|" "$PRIV/.env"
echo " created $PRIV/.env (INTEGRATION_MODE=mock — add API keys, then set live)"
else
echo " kept existing $PRIV/.env"
fi
chmod 640 "$PRIV/.env"
# --- assemble .htaccess: cPanel blocks + [pre-launch redirect] + site config
{
[[ -f "$DOCROOT/.htaccess.cpanel-blocks" ]] && cat "$DOCROOT/.htaccess.cpanel-blocks" && echo
if [[ "$KEEP" == "1" ]]; then cat "$DOCROOT/.htaccess.prelaunch"; fi
sed "s|^# SetEnv VISIT_PRIVATE_DIR .*|SetEnv VISIT_PRIVATE_DIR $PRIV|" "$DOCROOT/.htaccess.visit"
} > "$DOCROOT/.htaccess.new"
mv -f "$DOCROOT/.htaccess.new" "$DOCROOT/.htaccess"
rm -f "$DOCROOT/.htaccess.visit" "$DOCROOT/.htaccess.prelaunch" "$DOCROOT/.htaccess.cpanel-blocks"
chmod 644 "$DOCROOT/.htaccess"
if [[ "$KEEP" == "1" ]]; then echo " .htaccess: PRE-LAUNCH redirect to Church Center is ACTIVE"; else echo " .htaccess: site is LIVE (no redirect)"; fi
# --- PHP binary for cron
PHP_BIN=$(command -v php || true)
for c in /usr/local/bin/php /opt/cpanel/ea-php83/root/usr/bin/php /opt/cpanel/ea-php82/root/usr/bin/php; do
[[ -z "$PHP_BIN" && -x "$c" ]] && PHP_BIN="$c"
done
echo " php: ${PHP_BIN:-NOT FOUND} $($PHP_BIN -r 'echo PHP_VERSION;' 2>/dev/null || true)"
# --- cron (idempotent)
CRON="*/5 * * * * VISIT_PRIVATE_DIR=$PRIV $PHP_BIN $DOCROOT/api/cron/retry-integrations.php >> $PRIV/logs/cron.log 2>&1"
if ! crontab -l 2>/dev/null | grep -qF "retry-integrations.php"; then
( crontab -l 2>/dev/null || true; echo "$CRON" ) | crontab -
echo " cron installed (every 5 minutes)"
else
echo " cron already present"
fi
# --- SSL
if uapi --output=json SSL start_autossl_check >/dev/null 2>&1; then echo " AutoSSL check requested"; else echo " (AutoSSL check not available via UAPI; use cPanel → SSL/TLS Status if needed)"; fi
echo " build: $(head -1 "$DOCROOT/BUILD.txt")"
REMOTE
echo "▶ verifying"
sleep 2
printf " https://%s/ → " "$FQDN"; curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" --max-time 20 "https://$FQDN/" || true
printf " /api/health.php → "; curl -s --max-time 20 "https://$FQDN/api/health.php" | head -c 600 || true
echo
if [[ $KEEP_REDIRECT -eq 1 ]]; then
echo "Deployed behind the pre-launch redirect. To go live: deploy/deploy.sh --no-build"
else
echo "Live. Next: fill in $REMOTE_PRIVATE/.env on the server, set INTEGRATION_MODE=live, and run docs/LAUNCH-CHECKLIST.md."
fi
+15
View File
@@ -0,0 +1,15 @@
# ---------------------------------------------------------------------------
# PRE-LAUNCH: keep sending visitors to the Church Center form until Waters
# Church flips the switch. The /api/ endpoints are exempt so the deployment
# can be verified (they are rate-limited and same-origin protected). To go live, delete this block (or run deploy/deploy.sh
# without --keep-redirect).
# ---------------------------------------------------------------------------
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_URI} !^/api/
RewriteCond %{REQUEST_URI} !^/404\.html$
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule ^(.*)$ https://waterschurch.churchcenter.com/people/forms/21186 [R=302,L]
</IfModule>
# --------------------------------------------------------------- end prelaunch
+86
View File
@@ -0,0 +1,86 @@
# ============================================================================
# visit.waterschurch.org — Apache configuration for the subdomain document root
# Copied to out/.htaccess by `npm run build` (scripts/postbuild.mjs).
# ============================================================================
Options -Indexes -MultiViews
DirectoryIndex index.html
ErrorDocument 404 /404.html
# cPanel's default 403 page does not exist in this document root; show our branded page instead (status stays 403).
ErrorDocument 403 /404.html
# --- Private configuration directory for the PHP API -----------------------
# Uncomment and set to the absolute path of the folder holding your .env file
# (OUTSIDE the document root). If omitted, the API looks for
# <parent of document root>/visit-private, e.g. /home/USER/visit-private.
# SetEnv VISIT_PRIVATE_DIR /home/USER/visit-private
<IfModule mod_rewrite.c>
RewriteEngine On
# Force HTTPS and the canonical non-www host
RewriteCond %{HTTPS} !=on [OR]
RewriteCond %{HTTP_HOST} ^www\.visit\.waterschurch\.org$ [NC]
RewriteRule ^(.*)$ https://visit.waterschurch.org/$1 [R=301,L]
# Never serve dot-files (except the ACME challenge folder used for SSL issuance)
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule (^|/)\.(?!well-known) - [F,L]
# Next.js static export: serve /path as /path.html when the file exists
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/$1.html -f
RewriteRule ^(.+?)/?$ /$1.html [L]
# Strip trailing slash on non-directories
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.+)/$ /$1 [R=301,L]
</IfModule>
# --- Security headers -------------------------------------------------------
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(self), camera=(), microphone=(), payment=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on'"
# CSP is report-only until Waters Church confirms its tag/pixel list. See docs/DEPLOY-CPANEL.md.
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://connect.facebook.net https://analytics.tiktok.com https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://www.google-analytics.com https://analytics.google.com https://www.facebook.com https://analytics.tiktok.com https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; base-uri 'self'; form-action 'self' sms:; frame-ancestors 'self'"
# Immutable hashed assets
<FilesMatch "\.(js|css|woff2|avif|webp|jpg|jpeg|png|svg|ico)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
# HTML and text files: always revalidate
<FilesMatch "\.(html|xml|txt|json)$">
Header set Cache-Control "public, max-age=0, must-revalidate"
</FilesMatch>
</IfModule>
# --- Compression -----------------------------------------------------------
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml font/woff2
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml
</IfModule>
# --- MIME types some cPanel builds miss -------------------------------------
AddType image/avif .avif
AddType image/webp .webp
AddType font/woff2 .woff2
# --- Expires fallback (when mod_headers is unavailable) ---------------------
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/avif "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
ExpiresByType text/html "access plus 0 seconds"
</IfModule>