Files
HotLunches/square/onboard.php
T
2026-10-19 15:22:52 -07:00

674 lines
34 KiB
PHP

<?php
ini_set('display_errors', 0);
$ValidAccess = 2010;
// require_once("../include/globals.php");
$cdir = "/m/data1/app.hotlunches.net";
$AppDir = $cdir;
ob_start();
// Square redirects the seller's browser back to the ONE Redirect URL registered
// for the app, whichever school started onboarding. The host app resolves the
// school from the hostname (and bounces hosts that aren't a school, e.g.
// app.hotlunches.net → invalid-access.html), so the callback must NOT load the
// school/session includes or it would never run. It needs no session: it is
// authenticated by the single-use `state` token we generated, and MerchantStore
// loads only the logging/config/database includes itself.
$isSquareCallback = isset($_GET['code']) || isset($_GET['error']);
$isSquareCallback = $isSquareCallback && isset($_GET['state']);
if ($isSquareCallback) {
if (file_exists(__DIR__ . "/../include/logging-functions.php")) {
require_once(__DIR__ . "/../include/logging-functions.php");
}
if (!function_exists('writeLog')) { function writeLog($m = '') { error_log('[square] ' . $m); } }
if (!function_exists('startLog')) { function startLog($n = '') {} }
} elseif (file_exists(__DIR__ . "/../include/logging-functions.php")) {
// Host-app integration: logging, the session, and the admin login gate.
require_once(__DIR__ . "/../include/logging-functions.php");
startLog("includes");
require_once(__DIR__ . "/../include/date-time.php");
require_once(__DIR__ . "/../include/config.php");
require_once(__DIR__ . "/../include/school-functions.php");
require_once(__DIR__ . "/../include/database.php");
require_once(__DIR__ . "/../include/session.php");
} elseif (file_exists(__DIR__ . "/local-dev-bootstrap.php")) {
// Local dev only (stubs the host app); never deployed.
require_once(__DIR__ . "/local-dev-bootstrap.php");
}
if (!$isSquareCallback) {
$sessionName = 'hotlunch';
if (session_status() !== PHP_SESSION_ACTIVE) {
session_name($sessionName);
session_start();
}
if (SessionActive() != 1 || ! UserLoggedIn()) {
header("Location: {$FormTarget}");
exit();
}
$AllowedUserTypes = array('SuperUser','System Administrator');
if (checkPermission($AllowedUserTypes) != 1) {
$error_message = getLanguageText('ERRORMSG-ACCESS-DENIED');
writeLog($error_message);
$naction = '';
header("Location: {$FormTarget}?action=$action&naction=$naction$headerArgs&form_message=" . urlencode($form_message) . "&error_message=" . urlencode($error_message));
exit();
}
}
$buffer = ob_get_contents();
if (strlen($buffer)) writeLog("buffer: $buffer");
ob_end_clean();
writeLog("Header complete");
startLog("square-integration");
// redirect_uri sent to Square. Empty (the default) sends none, and Square uses
// the Redirect URL registered in the Developer Dashboard — correct for every
// school host. Never derive it from the current host: each school has its own
// hostname, and anything that differs from the registered URL is rejected.
function squareRedirectUri(array $config): string {
return trim($config['redirect_uri'] ?? '');
}
// ── Returning the admin to their OWN school site ─────────────────────────────
// Square sends every school back to the single Redirect URL registered for the
// app, not to the school's site. So when an admin clicks Connect on
// https://<schoolurl>/square/onboard.php we carry that URL inside the OAuth
// `state` (which Square echoes back verbatim), signed with our server key so it
// cannot be swapped for another site:
// state = <32-hex nonce>.<base64url(school onboard URL)>.<HMAC>
// Only the nonce is stored server-side (square_pending_onboarding.state), so no
// schema change is needed.
function squareStateKey(array $config): string {
$secret = (string)(($config['token_encryption_key'] ?? '') ?: ($config['client_secret'] ?? ''));
return hash('sha256', 'square-oauth-state|' . $secret, true);
}
// The URL of the onboarding page the admin is on right now (on their school's host).
function squareCurrentOnboardUrl(): string {
$scheme = 'http';
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
$scheme = 'https';
} elseif (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https') {
$scheme = 'https';
}
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
$path = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: ($_SERVER['SCRIPT_NAME'] ?? ''));
if (!preg_match('/^[A-Za-z0-9.-]+(:\d{1,5})?$/', $host) || !preg_match('#^/[A-Za-z0-9._~/-]*$#', $path)) {
return '';
}
return $scheme . '://' . $host . $path;
}
function squareMakeState(array $config, string $onboardUrl): array {
$nonce = bin2hex(random_bytes(16));
if ($onboardUrl === '') return [$nonce, $nonce];
$payload = rtrim(strtr(base64_encode($onboardUrl), '+/', '-_'), '=');
$sig = substr(hash_hmac('sha256', $nonce . '.' . $payload, squareStateKey($config)), 0, 32);
return [$nonce, $nonce . '.' . $payload . '.' . $sig];
}
// Returns [nonce, verified school onboard URL or ''].
function squareParseState(array $config, string $state): array {
if (preg_match('/^([0-9a-f]{32})\.([A-Za-z0-9_-]{1,700})\.([0-9a-f]{32})$/', $state, $m)) {
$expected = substr(hash_hmac('sha256', $m[1] . '.' . $m[2], squareStateKey($config)), 0, 32);
if (!hash_equals($expected, $m[3])) return ['', ''];
$url = (string)base64_decode(strtr($m[2], '-_', '+/'), true);
return [$m[1], preg_match('#^https?://[A-Za-z0-9.-]+(:\d{1,5})?/[A-Za-z0-9._~/-]*$#', $url) ? $url : ''];
}
return [preg_match('/^[0-9a-f]{32}$/', $state) ? $state : '', ''];
}
// The school's home URL: the onboarding URL minus its "/square/onboard.php" tail.
function squareSchoolHomeUrl(string $onboardUrl): string {
$p = parse_url($onboardUrl);
$base = rtrim(str_replace('\\', '/', dirname(dirname($p['path'] ?? '/'))), '/');
return $p['scheme'] . '://' . $p['host'] . (isset($p['port']) ? ':' . $p['port'] : '') . $base . '/';
}
// Finish the Square callback. When we know the school's site (from the signed
// state), send the admin back there:
// connected + payments ready → the school's home URL (button is live);
// connected but Square not ready → the school's onboarding page, which explains why;
// failed → the school's onboarding page with the error.
// Otherwise (legacy/unsigned state) render a self-contained page: this host is
// usually not the school's site, so it must not link into gated pages.
function squareCallbackPage(bool $ok, string $title, string $bodyHtml, bool $ready = false): void {
$GLOBALS['sqCallbackPageSent'] = true; // tells the shutdown guard a page was rendered
$onboardUrl = (string)($GLOBALS['sqReturnOnboardUrl'] ?? '');
if ($onboardUrl !== '' && !headers_sent()) {
if ($ok && $ready) {
$to = squareSchoolHomeUrl($onboardUrl);
} elseif ($ok) {
$to = $onboardUrl;
} else {
$msg = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags(str_replace('</p>', ' ', $bodyHtml)), ENT_QUOTES)));
$to = $onboardUrl . '?status=error&msg=' . rawurlencode($title . ': ' . $msg);
}
header('Location: ' . $to, true, 302);
exit();
}
if (!headers_sent()) header('Content-Type: text/html; charset=utf-8');
$color = $ok ? '#28a745' : '#c53030';
echo "<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>"
. "<title>School Square Setup</title></head>"
. "<body style='margin:0;font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;background:linear-gradient(135deg,#006aff 0%,#003a8c 100%);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;box-sizing:border-box'>"
. "<div style='background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.3);padding:36px;max-width:520px;width:100%;box-sizing:border-box;text-align:center;color:#444;line-height:1.6'>"
. "<div style='font-size:56px;color:$color'>" . ($ok ? '✓' : '!') . "</div>"
. "<h2 style='color:$color;margin:8px 0 14px'>" . htmlspecialchars($title) . "</h2>"
. $bodyHtml
. "<p style='margin-top:22px;font-size:14px;color:#666'>You can close this page and return to your school's HotLunches site.</p>"
. "</div></body></html>";
exit();
}
// The school this logged-in admin may connect/disconnect: ALWAYS the school of
// their own session ($school_record from the host app) — never a school id or
// name sent by the browser, or an admin of one school could attach their own
// Square account to another school and receive its payments. Platform
// SuperUsers working outside any school context may name the school by its
// unique record_num.
function squareAdminSchoolNum(): int {
global $school_record;
$num = (int)($school_record['record_num'] ?? 0);
if ($num > 0) return $num;
if (checkPermission(array('SuperUser')) == 1) return (int)($_POST['school_num'] ?? 0);
return 0;
}
// Handle AJAX requests
if (isset($_POST['action'])) writeLog("POST action: ".$_POST['action']);
// Admin actions only — never on a callback-shaped request, which skipped the login gate.
if (!$isSquareCallback && $_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json');
// Guarantee a JSON body even on a FATAL error (PHP `Error`, parse/type error,
// a broken DB connection inside db_query, an OOM, etc.). Without this, such
// failures emit a blank 500 body because display_errors is off, and the
// browser's r.json() throws "Unexpected end of JSON input" with no clue why.
// We gate strictly on a fatal having occurred so a normal successful exit()
// (which also triggers this shutdown handler) does NOT append a second body.
register_shutdown_function(function () {
$err = error_get_last();
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
if (!$err || !in_array($err['type'], $fatalTypes, true)) {
return;
}
$msg = 'Server error: ' . $err['message'];
if (function_exists('writeLog')) {
writeLog('onboard POST fatal: ' . $msg);
}
if (!headers_sent()) {
header('Content-Type: application/json');
}
echo json_encode(['success' => false, 'error' => $msg]);
});
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
try {
$client = new SquareClient($config);
$store = new MerchantStore($config);
// ACTION: Create an OAuth authorization link and hand it to the browser.
$schoolNum = squareAdminSchoolNum();
if ($schoolNum <= 0) {
echo json_encode(['success' => false, 'error' => 'Could not determine your school. Please sign in to your school account and try again.']);
exit();
}
if ($_POST['action'] === 'connect') {
// Confirm the school_num actually exists before starting OAuth, so the
// callback is guaranteed a row to attach to.
$resolvedName = $store->schoolNameByNum($schoolNum);
if ($resolvedName === null) {
echo json_encode(['success' => false, 'error' => 'School not found.']);
exit();
}
writeLog("connect school_num: $schoolNum ($resolvedName)");
// Cryptographically-random anti-CSRF nonce, plus this school's own
// onboarding URL (signed) so the callback can send the admin back to
// their school's site. The nonce keys the pending record.
[$nonce, $state] = squareMakeState($config, squareCurrentOnboardUrl());
$authUrl = $client->authorizeUrl($state, squareRedirectUri($config));
writeLog("connect: authorize URL issued for school_num $schoolNum");
// Persist the pending attempt in the DB (prunes stale ones first).
$store->createPending($nonce, $schoolNum);
echo json_encode(['success' => true, 'redirect' => $authUrl]);
exit();
}
// ACTION: Re-check a connected seller's readiness (locations / card capability).
if ($_POST['action'] === 'check_status') {
$accessToken = $store->ensureFreshToken('', $client, ['school_num' => $schoolNum])['access_token'] ?? null;
if (!$accessToken) {
echo json_encode(['success' => false, 'error' => 'Your school is not connected to Square yet']);
exit();
}
$sellerStatus = $client->getSellerStatus($accessToken);
writeLog('Seller status [manual] school_num ' . $schoolNum . ': square_ready=' . ($sellerStatus['square_ready'] ? 'true' : 'false'));
$store->mergeMetaByNum($schoolNum, [
'square_ready' => $sellerStatus['square_ready'] && !empty($sellerStatus['active_location_id']),
'active_location_id' => $sellerStatus['active_location_id'],
'merchant_name' => $sellerStatus['merchant_name'],
'currency' => $sellerStatus['currency'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
]);
echo json_encode([
'success' => true,
'square_ready' => $sellerStatus['square_ready'],
'active_location_id' => $sellerStatus['active_location_id'],
'currency' => $sellerStatus['currency'],
'issues' => $sellerStatus['issues'],
]);
exit();
}
// ACTION: Disconnect a seller — revoke their tokens at Square and clear ours.
if ($_POST['action'] === 'disconnect') {
$record = $store->getByNum($schoolNum);
if (!$record) {
echo json_encode(['success' => false, 'error' => 'Your school is not connected to Square']);
exit();
}
// Revoke the whole authorization at Square first (by merchant id). A
// revoke Square refused is logged; we still clear our copy so the
// button disappears, and the school can also remove the app in Square.
try {
$resp = $client->revokeToken('', (string)$record['merchant_id']);
if ($resp['status'] < 200 || $resp['status'] >= 300) {
writeLog('Revoke rejected for school_num ' . $schoolNum . ': HTTP ' . $resp['status'] . ' ' . ($resp['debug_id'] ?? ''));
}
} catch (\Throwable $e) {
writeLog('Revoke failed for school_num ' . $schoolNum . ': ' . $e->getMessage());
}
$store->removeConnectionByNum($schoolNum);
echo json_encode(['success' => true]);
exit();
}
} catch (\Throwable $e) {
// \Throwable (not just Exception) so PHP Errors — TypeError, a fatal from
// a broken db_query, etc. — are reported as JSON instead of a blank body.
if (function_exists('writeLog')) {
writeLog('onboard POST error: ' . $e->getMessage());
}
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
exit();
}
}
// Handle Square OAuth callback (top-level browser return from squareup.com).
if ($isSquareCallback) {
// Never log the query string: it carries the single-use code and state.
writeLog('Square callback received' . (isset($_GET['error']) ? ' (error=' . $_GET['error'] . ')' : ''));
// Never leave the seller on a blank page. Every normal path below ends in a
// squareCallbackPage(); if execution instead dies first (a fatal PHP Error, or
// a host db_query that exits the script on a query error), this shutdown
// guard renders a readable status page instead of an empty body.
register_shutdown_function(function () {
// A result page was rendered (headers_sent() can't tell when output is
// buffered), or real output already went out — nothing to rescue.
if (!empty($GLOBALS['sqCallbackPageSent']) || headers_sent()) {
return;
}
$err = error_get_last();
$detail = ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true))
? $err['message'] : '';
if (function_exists('writeLog')) {
writeLog('Square callback aborted before redirect' . ($detail !== '' ? (': ' . $detail) : ' (no PHP fatal captured — likely a DB query error)'));
}
$back = (string)($GLOBALS['sqReturnOnboardUrl'] ?? '');
if ($back !== '') {
while (ob_get_level() > 0) ob_end_clean();
header('Location: ' . $back . '?status=error&msg=' . rawurlencode('Square authorized the account, but saving the connection failed on our side. Please click Connect Square Account again.'), true, 302);
return;
}
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo "<!doctype html><meta charset='utf-8'>"
. "<div style='font-family:system-ui,Arial,sans-serif;max-width:560px;margin:60px auto;padding:24px;border:1px solid #e2e8f0;border-radius:12px'>"
. "<h2 style='color:#c53030;margin:0 0 8px'>Connection could not be completed</h2>"
. "<p style='color:#4a5568'>Square authorized the account, but saving the connection failed on our side. "
. "Please open Square setup on your school's HotLunches site and click <b>Connect Square Account</b> again.</p>"
. "</div>";
});
$config = require __DIR__ . '/config.php';
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$store = new MerchantStore($config);
// $state = the server-side nonce; the verified school onboarding URL (if the
// signature checks out) is where the admin is sent back to.
[$state, $GLOBALS['sqReturnOnboardUrl']] = squareParseState($config, (string)($_GET['state'] ?? ''));
$code = $_GET['code'] ?? '';
$oauthError = $_GET['error'] ?? '';
// 1) Validate state against our server-side pending store (CSRF protection).
// Only an unused attempt from the last 15 minutes is accepted, and it is
// burned right away, so a state value can never be replayed later to swap
// another Square account onto the school.
$pendingRow = $store->getPending($state);
if (!$state || $pendingRow === null) {
writeLog('Callback rejected: unknown, expired or already-used state');
squareCallbackPage(false, 'Link expired', '<p>This authorization link has expired or was already used. Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
$store->updatePending($state, 'processing', ['last_callback' => time()]);
// The pending record identifies the school by its unique school_num. Resolve
// the name for the name-keyed reads/embed/display below.
$schoolNum = (int)($pendingRow['school_num'] ?? 0);
$school = $schoolNum > 0 ? (string)($store->schoolNameByNum($schoolNum) ?? '') : '';
// 2) Seller declined or Square reported an error.
if ($oauthError !== '') {
$desc = $_GET['error_description'] ?? $oauthError;
writeLog('OAuth error for school_num ' . $schoolNum . ': ' . $oauthError . ' — ' . $desc);
$store->updatePending($state, 'declined', ['last_callback' => time()]);
squareCallbackPage(false, 'Not connected', '<p>Square authorization was not completed: ' . htmlspecialchars((string)$desc) . '</p>');
}
if ($code === '' || $schoolNum <= 0) {
squareCallbackPage(false, 'Not connected', '<p>Missing authorization code. Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
try {
$client = new SquareClient($config);
// 3) Exchange the authorization code for tokens (codes expire in 5 minutes).
$resp = $client->obtainToken($code, squareRedirectUri($config));
if ($resp['status'] < 200 || $resp['status'] >= 300 || empty($resp['body']['access_token'])) {
$detail = $resp['body']['errors'][0]['detail'] ?? ('HTTP ' . $resp['status']);
writeLog('Token exchange failed for ' . $school . ': ' . $detail);
$store->updatePending($state, 'token_exchange_failed', ['last_callback' => time()]);
squareCallbackPage(false, 'Not connected', '<p>Could not complete the connection with Square: ' . htmlspecialchars((string)$detail) . '</p><p>Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
$body = $resp['body'];
$accessToken = $body['access_token'];
$refreshToken = $body['refresh_token'] ?? '';
$merchantId = $body['merchant_id'] ?? '';
$expiresAt = $body['expires_at'] ?? null; // ISO 8601
// 4) Persist immediately (tokens encrypted at rest, in the database). The
// connection MUST be recorded even if the readiness check below fails.
// Keyed by school_num so duplicate school names can't misroute the write.
$saved = $store->saveConnectionByNum($schoolNum, $merchantId, $accessToken, $refreshToken, $expiresAt);
writeLog('Merchant saved [callback] school_num ' . $schoolNum . ' (' . $school . ') => ' . $merchantId . ' (' . ($saved ? 'ok' : 'WRITE FAILED') . ')');
if (!$saved) {
squareCallbackPage(false, 'Not saved', '<p>Square approved the connection, but it could not be saved for ' . htmlspecialchars($school !== '' ? $school : ('school #' . $schoolNum)) . '. Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
// 5) Mark the pending attempt connected.
$store->updatePending($state, 'connected', [
'merchant_id' => $merchantId,
'connected_at' => time(),
'last_callback' => time(),
]);
// 6) Record whether Square says this account can take payments right now
// (an ACTIVE location). That — not merely being connected — is what
// makes the "Pay with Square" button appear. Guarded so a failure never
// discards the connection we just saved; if the check fails, the button
// re-checks Square on its own (MerchantStore::isOnboarded).
$ready = false;
try {
$sellerStatus = $client->getSellerStatus($accessToken);
$ready = $sellerStatus['square_ready'] && !empty($sellerStatus['active_location_id']);
writeLog('Seller status [callback] ' . $school . ': square_ready=' . ($ready ? 'true' : 'false'));
if (!empty($sellerStatus['issues'])) {
writeLog('Seller status issues [callback] ' . $school . ': ' . implode(' | ', $sellerStatus['issues']));
}
$store->mergeMetaByNum($schoolNum, [
'square_ready' => $ready,
'active_location_id' => $sellerStatus['active_location_id'],
'merchant_name' => $sellerStatus['merchant_name'],
'currency' => $sellerStatus['currency'],
'country' => $sellerStatus['country'],
'status_issues' => $sellerStatus['issues'],
'status_checked_at' => time(),
]);
} catch (\Throwable $e) {
writeLog('Seller status check failed [callback] ' . $school . ': ' . $e->getMessage());
// Not ready yet; a null checked-at makes the next page view re-check Square.
$store->mergeMetaByNum($schoolNum, ['square_ready' => false, 'status_checked_at' => null]);
}
$schoolHtml = '<p style="font-weight:bold;color:#006aff;font-size:18px">' . htmlspecialchars($school !== '' ? $school : ('School #' . $schoolNum)) . '</p>';
if ($ready) {
$acct = !empty($sellerStatus['merchant_name']) ? ' (' . htmlspecialchars((string)$sellerStatus['merchant_name']) . ')' : '';
squareCallbackPage(true, 'Square account connected', $schoolHtml
. '<p>The <b>Pay with Square</b> button is now live for parents. Payments go directly to this school\'s Square account' . $acct . '.</p>', true);
}
$issues = '';
foreach (($sellerStatus['issues'] ?? []) as $issue) {
$issues .= '<li>' . htmlspecialchars((string)$issue) . '</li>';
}
squareCallbackPage(true, 'Connected — almost there', $schoolHtml
. '<p>Your Square account is connected, but Square says it can\'t take payments yet, so parents don\'t see the button. '
. 'It will appear automatically once this is fixed in your Square account.</p>'
. ($issues !== '' ? '<ul style="text-align:left">' . $issues . '</ul>' : ''));
} catch (\Throwable $e) {
writeLog('Square callback error [' . $school . ']: ' . $e->getMessage());
squareCallbackPage(false, 'Not connected', '<p>Something went wrong while connecting to Square. Please open Square setup on your school&#39;s site and click Connect again.</p>');
}
}
// Current Square status of THIS admin's school, for the page below.
require_once __DIR__ . '/lib/SquareClient.php';
require_once __DIR__ . '/lib/MerchantStore.php';
$pageConfig = require __DIR__ . '/config.php';
$pageStore = new MerchantStore($pageConfig);
$pageSchoolNum = (int)($school_record['record_num'] ?? 0);
$pageMeta = $pageSchoolNum > 0 ? $pageStore->getMetaByNum($pageSchoolNum) : [];
$pageConnected = !empty($pageMeta['merchant_id']) && empty($pageMeta['authorization_revoked']);
$pageLive = $pageConnected && $pageStore->isOnboarded($pageSchoolNum, new SquareClient($pageConfig));
if ($pageLive !== !empty($pageMeta['square_ready'])) {
$pageMeta = $pageStore->getMetaByNum($pageSchoolNum); // isOnboarded re-checked Square
}
$pageIssues = is_array($pageMeta['status_issues'] ?? null) ? $pageMeta['status_issues'] : [];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>School Square Account Setup</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #006aff 0%, #003a8c 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 500px;
width: 100%;
}
h1 { color: #333; margin-bottom: 10px; font-size: 28px; }
.subtitle { color: #666; margin-bottom: 30px; font-size: 14px; }
.form-group { margin-bottom: 20px; }
label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; }
input[type="text"] {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
}
input[type="text"]:focus { outline: none; border-color: #006aff; }
.btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #006aff 0%, #003a8c 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 20px rgba(0, 106, 255, 0.3); }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.btn.success { background: linear-gradient(135deg, #28a745 0%, #20c997 100%); }
.alert {
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 14px;
}
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.success-page { text-align: center; padding: 20px; }
.success-icon { font-size: 64px; color: #28a745; margin-bottom: 20px; }
.success-page h2 { color: #28a745; margin-bottom: 15px; font-size: 24px; }
.success-page p { color: #666; margin-bottom: 10px; line-height: 1.6; }
.school-name { font-weight: bold; color: #006aff; font-size: 18px; }
.alert-warning { background: #fff8e1; color: #7a5b00; border: 1px solid #ffe08a; }
.alert ul { margin: 6px 0 0 18px; }
.btn.secondary { background: #fff; color: #c53030; border: 2px solid #f5c6cb; margin-top: 10px; }
input[readonly] { background: #f5f7fa; color: #555; }
</style>
</head>
<body>
<div class="container">
<!-- Main Form -->
<h1>🏫 School Square Setup</h1>
<p class="subtitle">Connect your school's Square account</p>
<?php
$message = '';
$messageType = '';
if (isset($_GET['status']) && $_GET['status'] === 'error') {
$message = $_GET['msg'] ?? 'An error occurred. Please try again.';
$messageType = 'error';
}
if ($message): ?>
<div class="alert alert-<?php echo htmlspecialchars($messageType); ?>">
<?php echo htmlspecialchars($message); ?>
</div>
<?php endif; ?>
<?php if ($pageConnected && $pageLive): ?>
<div class="alert alert-success">
Connected to Square<?php echo !empty($pageMeta['merchant_name']) ? ' as <b>' . htmlspecialchars((string)$pageMeta['merchant_name']) . '</b>' : ''; ?>.
The <b>Pay with Square</b> button is live for parents.
</div>
<?php elseif ($pageConnected): ?>
<div class="alert alert-warning">
Connected to Square, but the account can't take payments yet, so parents don't see the button.
<?php if ($pageIssues): ?><ul><?php foreach ($pageIssues as $issue): ?><li><?php echo htmlspecialchars((string)$issue); ?></li><?php endforeach; ?></ul><?php endif; ?>
</div>
<?php endif; ?>
<form id="connectForm">
<?php if ($pageSchoolNum > 0): ?>
<!-- The school is always the one from the admin's own session; the
server ignores any school sent by the browser. -->
<div class="form-group">
<label for="school_name"><?php displayLanguageText('TEXT-SCHOOL-NAME'); ?></label>
<input type="text" id="school_name" value="<?php echo htmlspecialchars((string)($school_record['school_name'] ?? '')); ?>" readonly>
</div>
<?php else: ?>
<!-- No school context (platform SuperUser): name the school by its unique number. -->
<div class="form-group">
<label for="school_num">School number (record_num)</label>
<input type="text" id="school_num" name="school_num" inputmode="numeric" placeholder="e.g. 251" required>
</div>
<?php endif; ?>
<button type="submit" class="btn" id="connectBtn"><?php echo $pageConnected ? 'Reconnect Square Account' : 'Connect Square Account'; ?></button>
</form>
<?php if ($pageConnected): ?>
<button type="button" class="btn secondary" id="disconnectBtn">Disconnect Square</button>
<?php endif; ?>
</div>
<script>
var connectForm = document.getElementById('connectForm');
if (connectForm) connectForm.addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('connectBtn');
const schoolNumEl = document.getElementById('school_num');
const schoolNum = schoolNumEl ? schoolNumEl.value.trim() : '';
btn.disabled = true;
btn.textContent = 'Connecting...';
fetch('onboard.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=connect&school_num=' + encodeURIComponent(schoolNum)
})
.then(r => r.json())
.then(data => {
if (data.success && data.redirect) {
window.location.href = data.redirect;
} else {
alert('Error: ' + (data.error || 'Connection failed'));
btn.disabled = false;
btn.textContent = 'Connect Square Account';
}
})
.catch(err => {
alert('Error: ' + err.message);
btn.disabled = false;
btn.textContent = 'Connect Square Account';
});
});
var disconnectBtn = document.getElementById('disconnectBtn');
if (disconnectBtn) disconnectBtn.addEventListener('click', function() {
if (!confirm('Disconnect Square? Parents will no longer see the Pay with Square button.')) return;
disconnectBtn.disabled = true;
fetch('onboard.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'action=disconnect'
})
.then(r => r.json())
.then(data => {
if (data.success) { window.location.href = 'onboard.php'; }
else { alert('Error: ' + (data.error || 'Disconnect failed')); disconnectBtn.disabled = false; }
})
.catch(err => { alert('Error: ' + err.message); disconnectBtn.disabled = false; });
});
</script>
</body>
</html>