time() + 30 * 86400,
'path' => '/',
'secure' => is_https(),
'httponly' => true,
'samesite' => 'Lax',
]);
$qs = $_SERVER['QUERY_STRING'] ?? '';
redirect('/' . ($qs !== '' ? '?' . $qs : ''));
}
if (($_COOKIE['hggc_21'] ?? '') !== '1' && !in_array($view, ['success', 'cancel'], true)) {
page_start($config['site_name']);
?>
Welcome to the club.
Hit Greens Golf Club is a social golf club for cannabis-friendly adults.
You must be 21 or older to continue.
'', 'email' => '', 'phone' => '', 'slots' => 1];
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['book'])) {
csrf_check();
$view = 'tee';
$_GET['id'] = $_POST['tee_time_id'] ?? 0;
$old = [
'name' => trim((string)($_POST['name'] ?? '')),
'email' => trim((string)($_POST['email'] ?? '')),
'phone' => trim((string)($_POST['phone'] ?? '')),
'slots' => (int)($_POST['slots'] ?? 1),
];
try {
redirect(start_checkout((int)$_GET['id'], $old, isset($_POST['is21'])));
} catch (UserError $e) {
$errors[] = $e->getMessage();
} catch (Throwable $e) {
error_log('HGGC checkout: ' . $e->getMessage());
$errors[] = "We couldn't start checkout just now. Please try again in a minute.";
}
}
function start_checkout(int $teeId, array $in, bool $is21): string
{
global $config, $payments_ready;
if (!$payments_ready) throw new UserError('Booking opens soon. Check back shortly.');
if ($in['name'] === '' || mb_strlen($in['name']) > 80) throw new UserError('Please enter your name.');
if (!filter_var($in['email'], FILTER_VALIDATE_EMAIL)) throw new UserError('Please enter a valid email address.');
if (mb_strlen($in['phone']) > 30) throw new UserError('That phone number is too long.');
if (!$is21) throw new UserError('Please confirm you are 21 or older.');
$pdo = db();
$pdo->exec('BEGIN IMMEDIATE');
try {
$t = tee_time($teeId);
if (!$t || tee_state($t) !== 'open') throw new UserError('Sorry, this tee time is no longer taking bookings.');
$left = spots_left($t);
if ($in['slots'] < 1 || $in['slots'] > $left) {
throw new UserError($left === 1 ? 'Only 1 spot is left.' : "Only $left spots are left.");
}
$st = $pdo->prepare("SELECT COUNT(*) FROM bookings WHERE status = 'pending' AND hold_expires_at > ? AND ip = ?");
$st->execute([time(), client_ip()]);
if ($st->fetchColumn() >= 2) {
throw new UserError('You already have a checkout in progress. Finish it, or wait about 30 minutes for the hold to release.');
}
$token = bin2hex(random_bytes(16));
$expires_at = time() + $config['hold_minutes'] * 60;
$pdo->prepare("INSERT INTO bookings
(tee_time_id, token, name, email, phone, slots, amount_cents, status, hold_expires_at, ip, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)")
->execute([$teeId, $token, $in['name'], $in['email'], $in['phone'], $in['slots'],
$in['slots'] * $t['price_cents'], $expires_at + 120, client_ip(), time()]);
$bookingId = (int)$pdo->lastInsertId();
$pdo->exec('COMMIT');
} catch (Throwable $e) {
$pdo->exec('ROLLBACK');
throw $e;
}
try {
$session = stripe('POST', 'checkout/sessions', [
'mode' => 'payment',
'payment_method_types' => ['card'],
'customer_email' => $in['email'],
'client_reference_id' => $bookingId,
'expires_at' => $expires_at,
'line_items' => [[
'price' => stripe_price_for($t),
'quantity' => $in['slots'],
]],
'metadata' => ['booking_id' => $bookingId, 'tee_time_id' => $teeId],
'success_url' => base_url() . '/?view=success&session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => base_url() . '/?view=cancel&b=' . $token,
], 'hggc-booking-' . $token);
} catch (Throwable $e) {
db()->prepare("UPDATE bookings SET status = 'cancelled' WHERE id = ?")->execute([$bookingId]);
throw $e;
}
db()->prepare('UPDATE bookings SET stripe_session_id = ? WHERE id = ?')->execute([$session['id'], $bookingId]);
return $session['url'];
}
// --- Views ---------------------------------------------------
$nav = 'Tee times ';
if ($view === 'success') {
$booking = null;
$sessionId = (string)($_GET['session_id'] ?? '');
if (preg_match('/^cs_[A-Za-z0-9_]+$/', $sessionId)) {
try {
apply_checkout_session(stripe('GET', 'checkout/sessions/' . $sessionId));
} catch (Throwable $e) {
error_log('HGGC success page: ' . $e->getMessage()); // the webhook will still confirm it
}
$st = db()->prepare('SELECT * FROM bookings WHERE stripe_session_id = ?');
$st->execute([$sessionId]);
$booking = $st->fetch() ?: null;
}
$t = $booking ? tee_time((int)$booking['tee_time_id']) : null;
page_start("You're in", $nav);
if (!$booking || !$t): ?>
Thanks!
We couldn't find that booking. If you were charged, your spot is safe; check your email for the Stripe receipt.
Back to tee times
Booking confirmed
You're on the tee sheet.
= h($t['course']) ?> · = h(when($t['tee_at'])) ?>
= (int)$booking['slots'] ?> spot= $booking['slots'] > 1 ? 's' : '' ?> ·
= h(money((int)$booking['amount_cents'])) ?> paid. A receipt is on its way to = h($booking['email']) ?>.
= tee_state($t) === 'full' ? 'Your party is full and locked. See you out there.' : 'We\'ll keep filling the rest of your party until it locks.' ?>
View your tee time
Almost there.
We're still waiting for Stripe to confirm your payment. Refresh this page in a moment.
Refresh
prepare('SELECT * FROM bookings WHERE token = ?');
$st->execute([(string)($_GET['b'] ?? '')]);
$booking = $st->fetch();
if ($booking && $booking['status'] === 'pending' && $booking['stripe_session_id']) {
// Close the Stripe page first so nobody can pay for a spot we've released.
try {
$session = stripe('GET', 'checkout/sessions/' . $booking['stripe_session_id']);
if (($session['status'] ?? '') === 'open') {
$session = stripe('POST', 'checkout/sessions/' . $booking['stripe_session_id'] . '/expire');
}
apply_checkout_session($session);
db()->prepare("UPDATE bookings SET status = 'cancelled' WHERE id = ? AND status IN ('pending', 'expired')")
->execute([$booking['id']]);
} catch (Throwable $e) {
error_log('HGGC cancel: ' . $e->getMessage()); // the hold expires on its own
}
}
redirect($booking ? '/?view=tee&id=' . (int)$booking['tee_time_id'] . '&cancelled=1' : '/');
}
if ($view === 'tee') {
$t = tee_time((int)($_GET['id'] ?? 0));
if (!$t || $t['cancelled']) {
http_response_code(404);
page_start('Not found', $nav);
echo 'Tee time not found. It may have been cancelled.
'
. 'See all tee times ';
page_end();
exit;
}
$state = tee_state($t);
$left = spots_left($t);
$players = players((int)$t['id']);
page_start($t['course'], $nav);
?>
← All tee times
= h($config['city']) ?>
= h($t['course']) ?>
= h(when($t['tee_at'])) ?>
= h($t['address']) ?>
Party of = (int)$t['party_size'] ?>
= party_dots($t) ?>
= state_badge($t) ?>
= countdown($t) ?>
= nl2br(h($t['notes'])) ?>
= h(money((int)$t['price_cents'])) ?> per player
Checkout cancelled. You weren't charged and your hold was released.
= h($err) ?>
Booking opens soon. This tee time will take bookings once the site goes live.
The last spots are on hold while someone checks out. If they don't finish within 30 minutes, the spots open back up.
This party is full and locked.
Booking is closed for this tee time.
:after', [':after' => time()]);
page_start($config['site_name'], $nav);
?>
= h($config['city']) ?> · Pilot
Tee times with good company.
Meet cannabis-friendly golfers in San Diego. Grab a spot in a twosome or foursome.
Each tee time stays open for a week, or until the party fills.
Preview: booking opens soon. Look around and pick your tee time.
No tee times posted yet.
New tee times are posted every week. Check back soon.