prepare(); * - {name} table names, expanded to the prefixed table; * - integer columns returned as PHP ints (like the rest of the code expects), NULL as null; * - errors raised as DbError instead of being printed; * - transactions with READ COMMITTED isolation and SELECT ... FOR UPDATE row locks, which is what * prevents two buyers from taking the last seats at the same time. */ final class DbError extends RuntimeException {} final class Result implements \IteratorAggregate { public const ASSOC = 0; public const COLUMN = 1; public const KEY_PAIR = 2; public function __construct(private array $rows, private int $affected) {} public function fetchAll(int $mode = self::ASSOC): array { if ($mode === self::COLUMN) return array_map(fn($r) => reset($r), $this->rows); if ($mode === self::KEY_PAIR) { $out = []; foreach ($this->rows as $r) { $v = array_values($r); $out[$v[0]] = $v[1] ?? null; } return $out; } return $this->rows; } public function fetch(): array|false { return $this->rows[0] ?? false; } public function fetchColumn(): mixed { return $this->rows ? reset($this->rows[0]) : false; } public function rowCount(): int { return $this->affected; } public function getIterator(): \ArrayIterator { return new \ArrayIterator($this->rows); } } /** Table name with the WordPress prefix. {orders} in SQL becomes wp_gatecraft_orders. */ function tbl(string $name): string { global $wpdb; if ($name === 'wpusers') return $wpdb->users; return $wpdb->prefix . 'gatecraft_' . $name; } function sqlx(string $sql): string { return preg_replace_callback('/\{([a-z_]+)\}/', fn($m) => '`' . tbl($m[1]) . '`', $sql); } /** Fill "?" placeholders safely through $wpdb->prepare(). */ function db_bind(string $sql, array $args): string { global $wpdb; $sql = sqlx($sql); if (!$args) return $sql; $sql = str_replace('%', '%%', $sql); $vals = []; $i = 0; $args = array_values($args); $sql = preg_replace_callback('/\?/', function () use (&$i, $args, &$vals) { if (!array_key_exists($i, $args)) throw new DbError('Not enough values for the query placeholders.'); $v = $args[$i++]; if ($v === null) return 'NULL'; if (is_bool($v)) { $vals[] = (int)$v; return '%d'; } if (is_int($v)) { $vals[] = $v; return '%d'; } if (is_float($v)) { $vals[] = $v; return '%f'; } $vals[] = (string)$v; return '%s'; }, $sql); if ($i !== count($args)) throw new DbError('Too many values for the query placeholders.'); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- the SQL is built from fixed strings; every value is a placeholder. return $vals ? $wpdb->prepare($sql, ...$vals) : str_replace('%%', '%', $sql); } /** "?,?,?" for an IN (...) list: one placeholder per value, so every value goes through $wpdb->prepare(). */ function placeholders(array $values): string { return $values ? implode(',', array_fill(0, count($values), '?')) : 'NULL'; } function q(string $sql, array $args = []): Result { global $wpdb; $prepared = db_bind($sql, $args); $suppress = $wpdb->suppress_errors(true); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- custom tables; prepared in db_bind(). $res = $wpdb->query($prepared); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- built by db_bind() with $wpdb->prepare() placeholders $wpdb->suppress_errors($suppress); if ($res === false) { $errno = str_contains((string)$wpdb->last_error, 'Duplicate entry') ? 1062 : 0; throw new DbError(esc_html('Database error: ' . $wpdb->last_error), (int)$errno); } $rows = []; if (is_array($wpdb->last_result) && $wpdb->last_result) { $types = (array)$wpdb->get_col_info('type', -1); $names = (array)$wpdb->get_col_info('name', -1); $intCols = []; foreach ($names as $k => $n) if (in_array((int)($types[$k] ?? 0), [1, 2, 3, 8, 9, 13], true)) $intCols[$n] = true; foreach ($wpdb->last_result as $obj) { $r = (array)$obj; foreach ($intCols as $n => $_) if (isset($r[$n])) $r[$n] = (int)$r[$n]; $rows[] = $r; } } return new Result($rows, is_int($res) ? $res : 0); } function q1(string $sql, array $args = []): ?array { $r = q($sql, $args)->fetch(); return $r ?: null; } function qv(string $sql, array $args = []) { return q($sql, $args)->fetchColumn(); } function db_insert_id(): int { global $wpdb; return (int)$wpdb->insert_id; } /** Run $fn in a transaction (nested calls join the outer one). */ function tx(callable $fn) { static $depth = 0; if ($depth > 0) return $fn(); q('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); q('START TRANSACTION'); $depth++; try { $r = $fn(); q('COMMIT'); return $r; } catch (Throwable $t) { try { q('ROLLBACK'); } catch (Throwable $ignored) {} throw $t; } finally { $depth--; } } /** Dates in the site's time zone (Settings > General), without changing PHP's default (WordPress expects UTC). */ function ldate(string $format, ?int $ts = null): string { return (new DateTimeImmutable('@' . ($ts ?? time())))->setTimezone(wp_timezone())->format($format); } function lstrtotime(string $s) { try { return (new DateTimeImmutable($s, wp_timezone()))->getTimestamp(); } catch (Exception $e) { return false; } } function now(): string { return ldate('Y-m-d H:i:s'); } function e($s): string { return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } /** Map an internal path ("/e/slug", "/admin/orders/5", "/uploads/x.png") to a real URL. */ function map_url(string $u): string { if ($u === '' || $u[0] !== '/' || str_starts_with($u, '//')) return $u; if ($u === '/admin' || str_starts_with($u, '/admin/') || str_starts_with($u, '/admin?')) return gatecraft_admin_url($u); if (str_starts_with($u, '/uploads/')) return gatecraft_uploads()['url'] . '/' . basename(wp_parse_url($u, PHP_URL_PATH)); if (str_starts_with($u, '/assets/')) return GATECRAFT_URL . 'assets/' . substr($u, 8); $path = wp_parse_url($u, PHP_URL_PATH) ?? '/'; $qs = wp_parse_url($u, PHP_URL_QUERY); $frag = wp_parse_url($u, PHP_URL_FRAGMENT); $out = home_url('/' . gatecraft_base() . ($path === '/' ? '/' : rtrim($path, '/') . '/')); return $out . ($qs ? '?' . $qs : '') . ($frag ? '#' . $frag : ''); } /** "/admin/events/5/tickets?x=1" -> wp-admin/admin.php?page=gatecraft-events&r=/events/5/tickets&x=1 */ function gatecraft_admin_url(string $u): string { $path = wp_parse_url($u, PHP_URL_PATH) ?: '/admin'; $qs = wp_parse_url($u, PHP_URL_QUERY); $frag = wp_parse_url($u, PHP_URL_FRAGMENT); $rest = substr($path, 6) ?: ''; $seg = explode('/', ltrim($rest, '/'))[0] ?? ''; $page = gatecraft_admin_page_for($seg); // A screen from an add-on that is not active (for example an old notification about approvals): // open the closest free screen instead of a WordPress "not allowed" page. if (!isset(admin_pages()[$page])) { [$page, $rest, $qs] = $seg === 'approvals' ? ['gatecraft-orders', '', 'status=awaiting_approval'] : ['gatecraft', '', '']; } $args = 'page=' . $page . ($rest !== '' && $rest !== '/' ? '&r=' . rawurlencode($rest) : ''); return admin_url('admin.php?' . $args . ($qs ? '&' . $qs : '')) . ($frag ? '#' . $frag : ''); } function gatecraft_admin_page_for(string $seg): string { $map = ['' => 'gatecraft', 'notifications' => 'gatecraft', 'events' => 'gatecraft-events', 'categories' => 'gatecraft-events', 'tiers' => 'gatecraft-events', 'questions' => 'gatecraft-events', 'venues' => 'gatecraft-venues', 'discounts' => 'gatecraft-discounts', 'approvals' => 'gatecraft-approvals', 'orders' => 'gatecraft-orders', 'tickets' => 'gatecraft-orders', 'reports' => 'gatecraft-reports', 'export' => 'gatecraft-reports', 'checkin' => 'gatecraft-checkin', 'users' => 'gatecraft-users', 'settings' => 'gatecraft-settings', 'emails' => 'gatecraft-emails', 'audit' => 'gatecraft-audit', 'wallet' => 'gatecraft-settings', 'import' => 'gatecraft-audience', 'about' => 'gatecraft-about', 'release-notes' => 'gatecraft-release-notes', 'license' => 'gatecraft-license']; return $map[$seg] ?? 'gatecraft'; } function url(string $path = '/', array $query = []): string { $u = map_url('/' . ltrim($path, '/')); return $query ? $u . (str_contains($u, '?') ? '&' : '?') . http_build_query($query) : $u; } function redirect(string $to, int $code = 303): never { gatecraft_session_save(); nocache_headers(); // wp_safe_redirect() only: this site, or the payment hosts allowed in gatecraft_redirect_hosts() (anything else goes to the dashboard). wp_safe_redirect(map_url($to), $code); exit; } /** Only allow same-site relative paths (prevents open redirects). */ function safe_next(?string $next, string $fallback = '/'): string { $next = (string)$next; $decoded = rawurldecode($next); if ($next === '' || $next[0] !== '/' || str_starts_with($next, '//') || str_contains($next, '\\') || str_starts_with($decoded, '//') || str_contains($decoded, '\\') || preg_match('/[\x00-\x1f]/', $decoded)) { return $fallback; } return $next; } function flash(string $type, string $msg): void { sess_push('flash', [$type, $msg]); } function take_flashes(): array { $f = sess_get('flash', []); sess_del('flash'); return $f; } class UserError extends RuntimeException {} /** Thrown by abort() while a Tickets admin page is rendering; caught in gatecraft_admin_page(). */ /** Stops a Tickets admin page that is already rendering. The error to show is in $GLOBALS['gatecraft_admin_error']. */ class AdminAbort extends RuntimeException { } /** Stop with a message for the person. The message is HTML-escaped here; json_out() callers decode it again (see error_text()). */ function fail(string $msg): never { throw new UserError(esc_html($msg)); } /** A UserError message as plain text again, for JSON answers read by script (shown with textContent). */ function error_text(\Throwable $ex): string { return wp_specialchars_decode($ex->getMessage(), ENT_QUOTES); } function abort(int $code, string $msg = ''): never { if (is_admin() && !wp_doing_ajax() && headers_sent()) { // Inside a Tickets admin page that is already rendering: show the error in place, with a way back. $seg = explode('/', trim(substr(gatecraft_current_path(), 6), '/'))[0] ?? ''; $labels = ['events' => 'events', 'venues' => 'venues', 'discounts' => 'discounts', 'orders' => 'orders', 'tickets' => 'orders', 'approvals' => 'approvals', 'reports' => 'reports', 'checkin' => 'check-in', 'users' => 'staff and roles', 'settings' => 'settings', 'emails' => 'email log', 'audit' => 'audit log']; $seg = isset($labels[$seg]) ? $seg : ''; $titles = [403 => 'You do not have access to this', 404 => 'This page no longer exists']; $error = ['code' => $code, 'title' => $titles[$code] ?? 'Something went wrong', 'message' => $msg ?: 'Access denied.', 'back' => '/admin' . ($seg === 'tickets' ? '/orders' : ($seg ? '/' . $seg : '')), 'backLabel' => 'Back to ' . ($labels[$seg] ?? 'dashboard')]; // Inside our admin page callback: stop the handler and let gatecraft_admin_page() show the error, so WordPress // finishes the admin screen itself. if (!empty($GLOBALS['gatecraft_admin_rendering'])) { $GLOBALS['gatecraft_admin_error'] = $error; throw new AdminAbort(); } wp_die(esc_html($error['message']), esc_html($error['title']), ['response' => (int)$code, 'back_link' => true]); } if (is_admin() && !wp_doing_ajax()) wp_die(esc_html($msg ?: 'Access denied.'), esc_html__('Error', 'gatecraft'), ['response' => (int)$code, 'back_link' => true]); status_header($code); $titles = [403 => 'Access denied', 404 => 'Page not found', 405 => 'Method not allowed', 429 => 'Too many requests']; render('public/error', ['title' => $titles[$code] ?? 'Error', 'message' => $msg ?: ($titles[$code] ?? 'Error'), 'code' => $code]); exit; } function json_out(array $data, int $code = 200): never { header('Cache-Control: no-store'); wp_send_json(i18n_json($data), $code); exit; } /** Writes to the PHP error log only when WP_DEBUG is on, so production sites stay quiet. */ function log_debug(string $msg): void { if (defined('WP_DEBUG') && WP_DEBUG) error_log('Gatecraft: ' . $msg); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug mode only } /** A request header or server value, unslashed and sanitized as plain text. */ function server(string $k): string { return isset($_SERVER[$k]) ? sanitize_text_field(wp_unslash((string)$_SERVER[$k])) : ''; } /* * Form input. * * Every POST request first goes through dispatch(), which stops it unless the WordPress nonce, the session token and the * Origin header are valid, and then checks the route's capability with current_user_can() (csrf_verify() in security.php * and require_perm() in auth.php). On top of that, every form value is read through post_field() below, which verifies * the nonce again as its own first step and returns nothing if it fails. Only then is the field read, and it is sanitized * for its type as it is read. The only other places that read $_POST or $_FILES verify a nonce the same way first: * post_all() below, uploaded_file() in upload.php, csrf_verify() in security.php and the sign-in step in wp.php. */ /** * One submitted form field, sanitized for its type. Returns null when the nonce is not valid or the field was not sent. * line one line of text, sanitize_text_field() * text multi-line text, clean_text() * rich rich text from the editor, rich_clean() (wp_kses() with a fixed allow list) * url web address, esc_url_raw() with https and http only * path local address (path and query), esc_url_raw(), must start with one "/" * password exactly as typed (as in WordPress core): only compared or handed to OpenSSL, never stored as text or printed * json decoded JSON, every text leaf clean_text() * query a query string, every value sanitize_text_field() * list an array field (qty[3]=2), every leaf clean_text() * flag 1 when the field is sent and not empty, else 0 * sent true when the field is sent at all */ function post_field(string $k, string $type = 'line') { // 1. Verify the nonce, on its own. If it is missing or not valid, nothing is read. if (!isset($_POST['_gcnonce'])) { return null; } if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_gcnonce'])), 'gatecraft')) { return null; } // 2. Only then read the field, sanitized for its type. if ($type === 'sent') return isset($_POST[$k]); if ($type === 'flag') return !empty($_POST[$k]) ? 1 : 0; if (!isset($_POST[$k])) return null; if ($type === 'list') { if (!is_array($_POST[$k])) return null; return map_deep(wp_unslash($_POST[$k]), __NAMESPACE__ . '\\clean_text'); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every leaf goes through clean_text() } if (!is_string($_POST[$k])) return null; switch ($type) { case 'line': return sanitize_text_field(wp_unslash($_POST[$k])); case 'text': return trim(clean_text(wp_unslash($_POST[$k]))); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- clean_text() is the sanitizer case 'rich': // 1. wp_kses() with the editor's allow list. script, style and template are kept for one step (no attributes), only // so that 2. rich_clean() can remove them together with their contents instead of leaving their text behind. // 3. A final wp_kses() with the editor's allow list alone. $html = wp_kses(wp_unslash($_POST[$k]), rich_allowed_html() + ['script' => [], 'style' => [], 'template' => []]); return wp_kses(rich_clean(trim($html)), rich_allowed_html()); case 'url': return trim(esc_url_raw(wp_unslash($_POST[$k]), ['https', 'http'])); case 'path': $v = esc_url_raw(wp_unslash($_POST[$k])); return str_starts_with($v, '/') && !str_starts_with($v, '//') ? $v : ''; case 'password': return (string)wp_unslash($_POST[$k]); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a password is used as typed (as in WordPress core); never stored as text or printed case 'json': $v = json_decode((string)wp_unslash($_POST[$k]), true); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- decoded here, every text leaf is sanitized on the next line return is_array($v) ? map_deep($v, fn($x) => is_string($x) ? clean_text($x) : (is_scalar($x) || $x === null ? $x : null)) : null; case 'query': parse_str((string)wp_unslash($_POST[$k]), $q); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- split here, every value is sanitized on the next line return http_build_query(map_deep($q, 'sanitize_text_field')); } return null; } /** One-line text field. */ function post(string $k, $default = '') { return post_field($k, 'line') ?? $default; } function post_int(string $k, int $default = 0): int { $v = post($k); return is_string($v) && preg_match('/^-?\d+$/', $v) ? (int)$v : $default; } function post_bool(string $k): int { return (int)post_field($k, 'flag'); } /** True when the field was sent at all (checkbox groups send a hidden "_sent" marker). */ function post_has(string $k): bool { return (bool)post_field($k, 'sent'); } /** A local address to go back to; checked again by safe_next() where used. */ function post_path(string $k): string { return (string)(post_field($k, 'path') ?? ''); } /** A web address (https and http only). */ function post_url(string $k): string { return (string)(post_field($k, 'url') ?? ''); } /** A password or certificate passphrase, exactly as typed. */ function post_password(string $k): string { return (string)(post_field($k, 'password') ?? ''); } /** A JSON document sent by the admin screens (for example a page layout). */ function post_json(string $k): ?array { $v = post_field($k, 'json'); return is_array($v) ? $v : null; } /** A query string sent in a form field (to return to a filtered list). */ function post_query(string $k): string { return (string)(post_field($k, 'query') ?? ''); } /** Where a failed form goes back to: set by the handler, or the form's own _back field. */ function set_back(string $path): void { $GLOBALS['gatecraft_back'] = $path; } function back_target(): string { return isset($GLOBALS['gatecraft_back']) ? (string)$GLOBALS['gatecraft_back'] : post_path('_back'); } /** * Cleans one submitted text value: valid UTF-8 only, no control characters except tab and newline, * Windows line endings normalised. HTML is kept as text and escaped on output. Unlike * sanitize_textarea_field() it keeps percent sequences, so payment links in instructions survive. */ function clean_text($v): string { $v = wp_check_invalid_utf8(is_scalar($v) ? (string)$v : '', true); $v = str_replace("\r\n", "\n", $v); return (string)preg_replace('/[\x00-\x08\x0B-\x1F\x7F]/u', '', $v); } /** A submitted multi-line text field ('' when missing or not a string). */ function post_text(string $k): string { return (string)(post_field($k, 'text') ?? ''); } /** A submitted array field (qty[3]=2, om[0][name]=...). */ function post_list(string $k): array { $v = post_field($k, 'list'); return is_array($v) ? $v : []; } /** Every submitted field, sanitized with clean_text(), for refilling a form after an error. */ function post_all(): array { // 1. Verify the nonce, on its own. If it is missing or not valid, nothing is read. if (!isset($_POST['_gcnonce'])) { return []; } if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_gcnonce'])), 'gatecraft')) { return []; } // 2. Then read every field, sanitized. $all = map_deep(wp_unslash($_POST), __NAMESPACE__ . '\\clean_text'); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- every leaf goes through clean_text() return is_array($all) ? $all : []; } /** A query string value (filters, tabs, search), sanitized with sanitize_text_field(). Read-only: GET never changes data. */ function get_str(string $k, string $default = ''): string { if (!isset($_GET[$k]) || !is_string($_GET[$k])) return $default; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display value return sanitize_text_field(wp_unslash($_GET[$k])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display value } function get_int(string $k, int $default = 0): int { $v = get_str($k); return ctype_digit($v) ? (int)$v : $default; } /** A local address in the query string (for example ?next=/e/summer), sanitized with esc_url_raw(). */ function get_path(string $k): string { if (!isset($_GET[$k]) || !is_string($_GET[$k])) return ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only $v = esc_url_raw(wp_unslash($_GET[$k])); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only return str_starts_with($v, '/') && !str_starts_with($v, '//') ? $v : ''; } /** The whole query string, every value sanitized with sanitize_text_field(). Read-only (filters, rebuilding links). */ function get_all(): array { return map_deep(wp_unslash($_GET), 'sanitize_text_field'); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only } /** datetime-local input to SQL datetime, or null. */ /** A Y-m-d date from a filter field, or '' when it is missing, malformed or does not exist. */ function date_in(string $v): string { if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $v, $m)) return ''; return checkdate((int)$m[2], (int)$m[3], (int)$m[1]) && (int)$m[1] >= 2000 && (int)$m[1] <= 2099 ? $v : ''; } function dt_in(?string $v): ?string { $v = trim((string)$v); if ($v === '') return null; if (!preg_match('/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$/', $v)) return null; $d = DateTime::createFromFormat('Y-m-d\TH:i', $v) ?: DateTime::createFromFormat('Y-m-d H:i:s', $v) ?: DateTime::createFromFormat('Y-m-d H:i', $v); if (!$d) return null; $out = $d->format('Y-m-d H:i:s'); // Reject dates that do not exist (31 February) instead of rolling them over into the next month. return substr($out, 0, 10) === substr($v, 0, 10) ? $out : null; } function dt_val(?string $v): string { return $v ? ldate('Y-m-d\TH:i', lstrtotime($v)) : ''; } function date_pattern(): string { if (current_language() === 'ja') return 'Y年n月j日(D)'; return match (setting('date_format')) { 'ymd' => 'Y/m/d (D)', 'us' => 'D, M j, Y', default => 'D j M Y' }; } function time_pattern(): string { return setting('time_format') === '12' ? 'g:i a' : 'H:i'; } function fmt_dt(?string $dt): string { return $dt ? ldate_h(date_pattern() . (current_language() === 'ja' ? ' ' : ', ') . time_pattern(), lstrtotime($dt)) : ''; } function fmt_date(?string $dt): string { return $dt ? ldate_h(date_pattern(), lstrtotime($dt)) : ''; } function fmt_time(?string $dt): string { return $dt ? ldate_h(time_pattern(), lstrtotime($dt)) : ''; } /** A short date and time for busy lists: "1 Nov 13:04" this year, "1 Nov 2025" before. Japanese: "11/1 13:04". */ function fmt_short(?string $dt): string { if (!$dt) return ''; $ts = lstrtotime($dt); $ja = function_exists(__NAMESPACE__ . '\\current_language') && current_language() === 'ja'; if (ldate('Y', $ts) !== ldate('Y')) return ldate($ja ? 'Y/n/j' : 'j M Y', $ts); return ldate($ja ? 'n/j H:i' : 'j M H:i', $ts); } /** The payment method of a booking as a small coloured label with an icon. */ function payment_chip(array $o): string { $m = (string)($o['payment_method'] ?? ''); [$tone, $ic] = match ($m) { 'stripe' => ($o['pay_option'] ?? '') === 'Konbini' ? ['amber', 'building'] : ['blue', 'cart'], 'offline' => ['amber', 'clock'], 'free' => ['grey', 'ticket'], 'comp' => ['violet', 'star'], 'cash', 'door_card' => ['teal', 'scan'], 'imported' => ['slate', 'download'], default => ['grey', 'info'], }; $label = payment_label($o); if ($label === '') return ''; return '' . icon($ic) . '' . e($label) . ''; } function money(int $minor): string { $dec = (int)cfg('currency_decimals', 0); $cur = (string)cfg('currency', 'JPY'); $sym = ['JPY' => '¥', 'USD' => '$', 'EUR' => '€', 'GBP' => '£', 'INR' => '₹', 'AUD' => 'A$', 'CAD' => 'C$', 'SGD' => 'S$'][$cur] ?? ($cur . ' '); return $sym . number_format($minor / (10 ** $dec), $dec); } function to_minor(string $input): int { $dec = (int)cfg('currency_decimals', 0); $clean = preg_replace('/[^0-9.]/', '', $input); if ($clean === '' || substr_count($clean, '.') > 1) return 0; return max(0, (int)round(((float)$clean) * (10 ** $dec))); } function from_minor(int $minor): string { $dec = (int)cfg('currency_decimals', 0); return number_format($minor / (10 ** $dec), $dec, '.', ''); } function slugify(string $s): string { $s = strtolower(trim((string)preg_replace('/[^A-Za-z0-9]+/', '-', $s), '-')); return $s !== '' ? substr($s, 0, 100) : 'event-' . bin2hex(random_bytes(3)); } /** Crockford base32 (no I, L, O, U): readable, unambiguous codes. */ function random_code(int $len): string { $alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; $out = ''; for ($i = 0; $i < $len; $i++) $out .= $alphabet[random_int(0, 31)]; return $out; } function format_code(string $code): string { return trim(chunk_split($code, 4, ' ')); } /** Status for an order, worded for how it is being paid. */ function order_status_label(array $o): string { if ($o['status'] === 'pending_payment' && ($o['payment_method'] ?? '') === 'free') return 'Email to confirm'; if ($o['status'] === 'pending_payment' && ($o['payment_method'] ?? '') === 'offline') return 'Payment to confirm'; if ($o['status'] === 'pending_payment' && ($o['payment_method'] ?? '') === 'stripe' && ($o['pay_option'] ?? '') === 'Konbini') return 'Awaiting konbini payment'; return status_label($o['status']); } function order_badge(array $o): string { return badge($o['status'], order_status_label($o)); } function payment_label(array $o): string { return match ($o['payment_method'] ?? null) { 'stripe' => ($o['pay_option'] ?? '') ?: 'Card', 'free' => 'Free', 'offline' => $o['pay_option'] ?: 'Bank transfer', 'comp' => 'Guest list', 'cash' => 'Cash at the door', 'door_card' => 'Card at the door', 'imported' => ($o['pay_option'] ?? '') ? 'Imported from ' . $o['pay_option'] : 'Imported', default => '', }; } /** The buyer's payment instructions for an offline order, with the amount and reference filled in. */ function order_pay_instructions(array $o): string { $tpl = (string)($o['pay_instructions'] ?? ''); if ($tpl === '' && ($o['payment_method'] ?? '') === 'offline') $tpl = setting('bank_instructions'); return fill_vars($tpl, ['amount' => money((int)$o['total']), 'booking_reference' => $o['reference'], 'pay_deadline' => fmt_dt($o['expires_at'] ?? null)]); } function status_label(string $s): string { return [ 'awaiting_approval' => 'Awaiting approval', 'pending_payment' => 'Awaiting payment', 'paid' => 'Confirmed', 'rejected' => 'Declined', 'cancelled' => 'Cancelled', 'expired' => 'Expired', 'refunded' => 'Refunded', 'draft' => 'Draft', 'published' => 'Published', 'archived' => 'Archived', ][$s] ?? ucfirst(str_replace('_', ' ', $s)); } function status_tone(string $s): string { return match ($s) { 'paid', 'published', 'ok', 'active', 'valid' => 'green', 'awaiting_approval', 'pending_payment', 'draft', 'duplicate' => 'amber', 'rejected', 'cancelled', 'refunded', 'void', 'invalid', 'wrong_event', 'disabled' => 'red', default => 'grey', }; } function plural(int $n, string $one, ?string $many = null): string { return $n . ' ' . ($n === 1 ? $one : ($many ?? $one . 's')); } /** * After a failed save, keep what the user typed so the form can be refilled (by app.js) instead of * making them start again. Passwords, tokens and files are never kept. */ function gatecraft_remember_input(string $path): void { $flat = []; foreach (post_all() as $k => $v) { if (!is_string($k) || preg_match('/^_|pass|secret|token|card|^(current|new|confirm|code|otp)$/i', $k)) continue; if (is_array($v)) { if (array_is_list($v)) $flat[$k . '[]'] = array_map(fn($x) => is_scalar($x) ? (string)$x : '', $v); else foreach ($v as $sk => $sv) if (is_scalar($sv)) $flat[$k . '[' . $sk . ']'] = (string)$sv; } elseif (is_scalar($v)) { $flat[$k] = mb_substr((string)$v, 0, 100000); } } $json = wp_json_encode(['action' => map_url($path), 'data' => $flat]); if ($json !== false && strlen($json) < 250000) sess_set('old_input', $json); } /** One-time attribute carrying the input kept by gatecraft_remember_input(). */ /** The form values to put back after an error, as JSON for data-old (read once), or '' when there are none. */ function old_input_json(): string { $j = sess_get('old_input'); sess_del('old_input'); return is_string($j) ? $j : ''; }