$d + [ 'contact_consent' => 'required', 'contact_consent_text' => 'I agree that {brand_name} may email me about future events and send me important updates about events I book. I can unsubscribe at any time.', ]); add_filter('gatecraft_setting_rules', function (array $r, string $tab) { return $tab === 'checkout' ? $r + ['contact_consent' => ['enum', ['required', 'optional', 'off']], 'contact_consent_text' => ['text', 500]] : $r; }, 10, 2); add_filter('gatecraft_admin_pages', fn(array $p) => $p + ['gatecraft-audience' => ['Audience', 'gatecraft_manage', '/audience']]); add_filter('gatecraft_admin_routes', fn(array $r) => array_merge($r, [['GET', '/admin/audience', 'a_audience', 'orders']])); /** Consent wording with the brand filled in. */ function contact_consent_text(): string { return fill_vars(setting('contact_consent_text'), ['brand_name' => setting('brand_name')]); } function contact_tags_clean(string $s): string { $tags = array_unique(array_filter(array_map(fn($t) => mb_substr(trim((string)preg_replace('/[^\p{L}\p{N} _-]/u', '', $t)), 0, 40), explode(',', $s)))); return implode(',', array_slice($tags, 0, 20)); } /** Add someone to the list, or update them, recording that they agreed. Returns the contact id. */ function contact_upsert(string $email, string $name, ?string $lang, string $source, string $tags = '', bool $resubscribe = true): int { $email = strtolower(trim($email)); if (!is_email($email)) return 0; $c = q1('SELECT * FROM {contacts} WHERE email = ?', [$email]); $consent = mb_substr(contact_consent_text(), 0, 500); if (!$c) { q('INSERT INTO {contacts} (email, name, language, status, source, consent_at, consent_text, tags, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)', [$email, mb_substr($name, 0, 120), $lang, 'subscribed', $source, now(), $source === 'checkout' ? $consent : null, contact_tags_clean($tags), now(), now()]); $id = (int)db_insert_id(); } else { $id = (int)$c['id']; $merged = contact_tags_clean($c['tags'] . ',' . $tags); if ($resubscribe) { // The person themselves agreed again: record it. The consent time changes only when they were not subscribed. $was = $c['status'] === 'subscribed'; q('UPDATE {contacts} SET name = IF(? = \'\', name, ?), language = COALESCE(?, language), status = \'subscribed\', unsubscribed_at = NULL, consent_at = ?, consent_text = COALESCE(?, consent_text), tags = ?, updated_at = ? WHERE id = ?', [$name, mb_substr($name, 0, 120), $lang, $was && $c['consent_at'] ? $c['consent_at'] : now(), $source === 'checkout' ? $consent : null, $merged, now(), $id]); } else { // Not confirmed to be this person (a guest checkout, an import): never re-subscribe, rename or re-date their consent. q('UPDATE {contacts} SET tags = ?, updated_at = ? WHERE id = ?', [$merged, now(), $id]); } } contact_refresh($email); return $id; } /** Recount bookings, tickets and spend for one email from its confirmed bookings. */ function contact_refresh(string $email): void { try { $s = q1("SELECT COUNT(DISTINCT o.id) AS n, COALESCE(SUM(o.total),0) AS spent, MIN(o.paid_at) AS first, MAX(o.paid_at) AS last FROM {orders} o WHERE o.buyer_email = ? AND o.status = 'paid'", [strtolower($email)]); $t = (int)qv("SELECT COUNT(*) FROM {tickets} tk JOIN {orders} o ON o.id = tk.order_id WHERE o.buyer_email = ? AND o.status = 'paid' AND tk.status = 'valid'", [strtolower($email)]); q('UPDATE {contacts} SET orders_count = ?, tickets_count = ?, total_spent = ?, first_order_at = ?, last_order_at = ? WHERE email = ?', [(int)$s['n'], $t, (int)$s['spent'], $s['first'], $s['last'], strtolower($email)]); } catch (\Throwable $e) { // Before the upgrade that adds the table. } } /** The list, view only. Gatecraft Pro replaces this page with filters, saved lists, tags, export and messages. */ function a_audience(): void { if (has_action('gatecraft_audience_page')) { do_action('gatecraft_audience_page'); return; } $q = mb_substr(get_str('q'), 0, 100); $w = '1=1'; $a = []; if ($q !== '') { $w = '(c.email LIKE ? OR c.name LIKE ?)'; $like = '%' . addcslashes($q, '%_\\') . '%'; $a = [$like, $like]; } $total = (int)qv("SELECT COUNT(*) FROM {contacts} c WHERE $w", $a); $page = max(1, get_int('page', 1)); $rows = q("SELECT c.* FROM {contacts} c WHERE $w ORDER BY c.created_at DESC, c.id DESC LIMIT 50 OFFSET ?", array_merge($a, [($page - 1) * 50]))->fetchAll(); $stats = q1("SELECT SUM(status = 'subscribed') AS subscribed, SUM(status = 'unsubscribed') AS unsubscribed, SUM(created_at >= ?) AS recent FROM {contacts}", [ldate('Y-m-d H:i:s', lstrtotime('-30 days'))]); render('admin/audience', ['title' => 'Audience', 'nav' => 'audience', 'q' => $q, 'rows' => $rows, 'total' => $total, 'page' => $page, 'stats' => array_map('intval', (array)$stats)]); } // ---------------------------------------------------------------- unsubscribe links function audience_unsub_sig(int $id, string $email): string { return substr(hash_hmac('sha256', 'unsubscribe|' . $id . '|' . strtolower($email), app_key()), 0, 32); } function audience_unsub_url(int $id, string $email, bool $oneClick = false): string { return absolute_url($oneClick ? '/unsubscribe/one-click' : '/unsubscribe', ['c' => $id . '.' . audience_unsub_sig($id, $email)]); } // ---------------------------------------------------------------- unsubscribe (public) function audience_contact_from_link(string $c): ?array { if (!preg_match('/^(\d+)\.([a-f0-9]{32})$/', $c, $m)) return null; $row = q1('SELECT * FROM {contacts} WHERE id = ?', [(int)$m[1]]); return $row && hash_equals(audience_unsub_sig((int)$row['id'], $row['email']), $m[2]) ? $row : null; } function h_unsubscribe(): void { header('Referrer-Policy: no-referrer'); header('X-Robots-Tag: noindex'); $row = audience_contact_from_link(get_str('c')); if ($row && $row['language']) language_context($row['language'], true); render('public/unsubscribe', ['title' => 'Email preferences', 'row' => $row, 'c' => get_str('c'), 'done' => get_str('done')]); } function h_unsubscribe_post(): void { $row = audience_contact_from_link(post('c')) ?? abort(404, 'This link is not valid.'); $again = post('action') === 'resubscribe'; q('UPDATE {contacts} SET status = ?, unsubscribed_at = ?, consent_at = IF(? = 1, ?, consent_at), updated_at = ? WHERE id = ?', [$again ? 'subscribed' : 'unsubscribed', $again ? null : now(), $again ? 1 : 0, now(), now(), $row['id']]); audit($again ? 'audience.resubscribed' : 'audience.unsubscribed', 'contact', (int)$row['id'], ['via' => 'link']); redirect('/unsubscribe?' . http_build_query(['c' => post('c'), 'done' => $again ? 'in' : 'out'])); } /** One-click unsubscribe from the mail app's button (RFC 8058). No form, no session: the signed link is the proof. */ function h_unsubscribe_one_click(): void { $row = audience_contact_from_link(get_str('c')); if ($row && $row['status'] === 'subscribed') { q("UPDATE {contacts} SET status = 'unsubscribed', unsubscribed_at = ?, updated_at = ? WHERE id = ?", [now(), now(), $row['id']]); audit('audience.unsubscribed', 'contact', (int)$row['id'], ['via' => 'one-click']); } status_header(200); header('Content-Type: text/plain; charset=utf-8'); echo esc_html('OK'); exit; }