From 3e18d71378633e090a14551a345ad9848071fd53 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 25 May 2026 18:31:12 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Parker=20County?= =?UTF-8?q?=20Slingshot=20Rentals=20booking=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full booking system with Square card-on-file, 10-step booking flow, pre-departure checklist, and Mailjet email integration. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 11 + .htaccess | 6 + admin/.htaccess | 5 + admin/index.php | 2093 ++++++++++++++++++++++++++++++++++++++++++++ admin/login.php | 36 + admin/logout.php | 7 + admin/view-doc.php | 39 + api/booking.php | 136 +++ availability.php | 47 + contact.php | 282 ++++++ db.php.example | 91 ++ index.html | 1216 +++++++++++++++++++++++++ waiver.php | 365 ++++++++ 13 files changed, 4334 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 admin/.htaccess create mode 100644 admin/index.php create mode 100644 admin/login.php create mode 100644 admin/logout.php create mode 100644 admin/view-doc.php create mode 100644 api/booking.php create mode 100644 availability.php create mode 100644 contact.php create mode 100644 db.php.example create mode 100644 index.html create mode 100644 waiver.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0636bdc --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Credentials (server-only — never commit real values) +db.php +config.php + +# Uploads / user files +uploads/ +*.log + +# OS / editor +.DS_Store +*.swp diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..411fcea --- /dev/null +++ b/.htaccess @@ -0,0 +1,6 @@ +DirectoryIndex index.html index.php + + + Order deny,allow + Deny from all + diff --git a/admin/.htaccess b/admin/.htaccess new file mode 100644 index 0000000..cc2c068 --- /dev/null +++ b/admin/.htaccess @@ -0,0 +1,5 @@ + +CacheEnable off + +Header always set Cache-Control "no-store, no-cache, must-revalidate" +Header always set Pragma "no-cache" diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..bfcea6d --- /dev/null +++ b/admin/index.php @@ -0,0 +1,2093 @@ +prepare("INSERT INTO admin_tokens (token, expires_at) VALUES (?, ?)") + ->execute([$token, date('Y-m-d H:i:s', time() + 86400)]); + db()->exec("DELETE FROM admin_tokens WHERE expires_at < NOW()"); + return $token; +} +function _verifyToken(string $token): bool { + if (!preg_match('/^[a-f0-9]{64}$/', $token)) return false; + $stmt = db()->prepare("SELECT token FROM admin_tokens WHERE token=? AND expires_at > NOW()"); + $stmt->execute([$token]); + return (bool)$stmt->fetch(); +} + +$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH']) || (($_SERVER['HTTP_ACCEPT'] ?? '') === 'application/json'); + +// ── Auth ────────────────────────────────────────────────────────────────────── +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') { + if (($_POST['username'] ?? '') === ADMIN_USER && password_verify($_POST['password'] ?? '', ADMIN_PASS)) { + $t = _createToken(); + header('Location: /admin/?_t=' . $t); + } else { + header('Location: /admin/?err=1'); + } + exit; +} +$rawToken = preg_replace('/[^a-f0-9]/', '', $_GET['_t'] ?? $_POST['_t'] ?? ''); +if (($_GET['action'] ?? '') === 'logout') { + if ($rawToken) db()->prepare("DELETE FROM admin_tokens WHERE token=?")->execute([$rawToken]); + header('Location: /admin/'); exit; +} +$authed = $rawToken !== '' && _verifyToken($rawToken); +$token = $authed ? $rawToken : ''; + +// ── AJAX handlers ───────────────────────────────────────────────────────────── +if ($isAjax && !$authed) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['error'=>'Session expired. Please log in again.']); + exit; +} +if ($isAjax) { + header('Content-Type: application/json'); + $action = $_POST['action'] ?? $_GET['action'] ?? ''; + + if ($action === 'update_status') { + $id = (int)($_POST['id'] ?? 0); + $status = $_POST['status'] ?? ''; + $allowed = ['pending','confirmed','completed','cancelled']; + if ($id && in_array($status, $allowed)) { + db()->prepare("UPDATE bookings SET status=? WHERE id=?")->execute([$status, $id]); + echo json_encode(['ok'=>true]); + } else { echo json_encode(['error'=>'Invalid']); } + exit; + } + + if ($action === 'save_admin_notes') { + $id = (int)($_POST['id'] ?? 0); + $notes = substr(trim($_POST['notes'] ?? ''), 0, 1000); + db()->prepare("UPDATE bookings SET admin_notes=? WHERE id=?")->execute([$notes, $id]); + echo json_encode(['ok'=>true]); + exit; + } + + if ($action === 'toggle_requirement') { + $id = (int)($_POST['id'] ?? 0); + $field = $_POST['field'] ?? ''; + $allowed_fields = ['insurance_verified','deposit_received','license_verified','helmet_provided','safety_course','operational_course']; + if ($id && in_array($field, $allowed_fields)) { + $stmt = db()->prepare("SELECT `{$field}` FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $current = (int)$stmt->fetchColumn(); + $new = $current ? 0 : 1; + db()->prepare("UPDATE bookings SET `{$field}`=? WHERE id=?")->execute([$new, $id]); + echo json_encode(['ok'=>true,'value'=>$new]); + } else { echo json_encode(['error'=>'Invalid']); } + exit; + } + + if ($action === 'send_reminder') { + $id = (int)($_POST['id'] ?? 0); + $keys = array_filter(explode(',', $_POST['items'] ?? '')); + $stmt = db()->prepare("SELECT * FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + if (!$b) { echo json_encode(['error'=>'Not found']); exit; } + + $pkg = PACKAGES[$b['package']] ?? ['label' => $b['package']]; + $dateLabel = date('F j, Y', strtotime($b['rental_date'])); + $ref = $b['booking_ref']; + + $itemDefs = [ + 'waiver' => [ + 'label' => 'Sign Your Rental Agreement', + 'detail' => 'Your digital rental agreement still needs to be signed before your pickup. It only takes a minute and can be done on any device — no printer required.', + 'cta' => "
Sign Agreement →
", + ], + 'insurance' => [ + 'label' => 'Proof of Personal Auto Insurance', + 'detail' => 'You\'ll need to bring proof of valid personal auto insurance to pickup. A photo on your phone of your insurance card is fine. This is required before we can hand over the keys.', + 'cta' => '', + ], + 'deposit' => [ + 'label' => 'Balance Due at Pickup', + 'detail' => 'Your $' . number_format(DEPOSIT_AMOUNT, 2) . ' deposit hold has been placed on your card. The remaining balance is due at pickup — cash or card accepted. Your deposit hold will be released upon safe return of the vehicle.', + 'cta' => '', + ], + 'license' => [ + 'label' => "Valid Driver's License", + 'detail' => "Please bring your valid driver's license to pickup. We're required to verify it before you take the Slingshot out. Must match the name on the booking.", + 'cta' => '', + ], + ]; + + $rowsHtml = ''; + $n = 1; + foreach ($keys as $key) { + if (!isset($itemDefs[$key])) continue; + $d = $itemDefs[$key]; + $rowsHtml .= " + + + {$n} + + + " . htmlspecialchars($d['label']) . " +

" . htmlspecialchars($d['detail']) . "

+ {$d['cta']} + +"; + $n++; + } + + if (!$rowsHtml) { echo json_encode(['error'=>'No items selected']); exit; } + + $html = " +
+
+

Parker County Slingshot Rentals

+
+
+

Almost Ready — A Few Things Before Pickup

+

Hey " . htmlspecialchars($b['name']) . ", your " . htmlspecialchars($pkg['label']) . " rental on {$dateLabel} is coming up! (Ref: {$ref})

+

To make sure pickup goes smoothly, here's what still needs to be taken care of:

+ {$rowsHtml}
+
+

Questions? Call or text (817) 555-0199 or reply to this email — we're happy to help.

+
+

Ride on,
The Parker County Slingshot Team

+
+
+

© " . date('Y') . " Parker County Slingshot Rentals — Weatherford, TX

+
+
"; + + $sent = sendEmail($b['email'], $b['name'], "Action Needed Before Your Rental — {$ref}", $html); + if ($sent) { + echo json_encode(['ok'=>true]); + } else { + echo json_encode(['ok'=>false,'error'=>'Email service not configured. Set MAILJET_API_KEY and MAILJET_SECRET_KEY in db.php.']); + } + exit; + } + + if ($action === 'square_capture') { + $id = (int)($_POST['id'] ?? 0); + $stmt = db()->prepare("SELECT square_payment_id FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + $pid = $b['square_payment_id'] ?? ''; + if (!$pid) { echo json_encode(['error'=>'No payment on file']); exit; } + $resp = squareApi('POST', "/payments/{$pid}/complete"); + if (($resp['payment']['status'] ?? '') === 'COMPLETED') { + db()->prepare("UPDATE bookings SET square_payment_status='COMPLETED', deposit_paid=?, deposit_received=1 WHERE id=?") + ->execute([DEPOSIT_AMOUNT, $id]); + echo json_encode(['ok'=>true,'status'=>'COMPLETED']); + } else { + echo json_encode(['error' => $resp['errors'][0]['detail'] ?? 'Capture failed']); + } + exit; + } + + if ($action === 'square_void') { + $id = (int)($_POST['id'] ?? 0); + $stmt = db()->prepare("SELECT square_payment_id FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + $pid = $b['square_payment_id'] ?? ''; + if (!$pid) { echo json_encode(['error'=>'No payment on file']); exit; } + $resp = squareApi('POST', "/payments/{$pid}/cancel"); + if (($resp['payment']['status'] ?? '') === 'CANCELED') { + db()->prepare("UPDATE bookings SET square_payment_status='CANCELED' WHERE id=?")->execute([$id]); + echo json_encode(['ok'=>true,'status'=>'CANCELED']); + } else { + echo json_encode(['error' => $resp['errors'][0]['detail'] ?? 'Void failed']); + } + exit; + } + + if ($action === 'square_refund') { + $id = (int)($_POST['id'] ?? 0); + $stmt = db()->prepare("SELECT square_payment_id, deposit_paid FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + $pid = $b['square_payment_id'] ?? ''; + if (!$pid) { echo json_encode(['error'=>'No payment on file']); exit; } + $cents = (int)(((float)($b['deposit_paid'] ?: DEPOSIT_AMOUNT)) * 100); + $resp = squareApi('POST', '/refunds', [ + 'idempotency_key' => $pid . '-refund-' . time(), + 'payment_id' => $pid, + 'amount_money' => ['amount' => $cents, 'currency' => 'USD'], + 'reason' => 'Security deposit refund — booking returned in good condition', + ]); + if (!empty($resp['refund']['id'])) { + db()->prepare("UPDATE bookings SET square_payment_status='REFUNDED', square_refund_id=?, deposit_paid=0 WHERE id=?") + ->execute([$resp['refund']['id'], $id]); + echo json_encode(['ok'=>true,'status'=>'REFUNDED']); + } else { + echo json_encode(['error' => $resp['errors'][0]['detail'] ?? 'Refund failed']); + } + exit; + } + + if ($action === 'charge_balance') { + $id = (int)($_POST['id'] ?? 0); + $stmt = db()->prepare("SELECT * FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + if (!$b) { echo json_encode(['error'=>'Not found']); exit; } + $cardId = $b['square_card_id'] ?? ''; + $customerId = $b['square_customer_id'] ?? ''; + if (!$cardId) { echo json_encode(['error'=>'No card on file']); exit; } + $balance = (float)$b['amount'] - DEPOSIT_AMOUNT; + if ($balance <= 0) { echo json_encode(['error'=>'No balance due']); exit; } + $cents = (int)($balance * 100); + $payBody = [ + 'source_id' => $cardId, + 'idempotency_key' => $b['booking_ref'] . '-bal-' . time(), + 'amount_money' => ['amount' => $cents, 'currency' => 'USD'], + 'autocomplete' => true, + 'location_id' => SQUARE_LOCATION_ID, + 'note' => "Balance charge — booking " . $b['booking_ref'], + 'reference_id' => $b['booking_ref'], + ]; + if ($customerId) $payBody['customer_id'] = $customerId; + $resp = squareApi('POST', '/payments', $payBody); + if (!empty($resp['payment']['id'])) { + $pid = $resp['payment']['id']; + db()->prepare("UPDATE bookings SET square_payment_status='BAL_COMPLETED', deposit_received=1 WHERE id=?") + ->execute([$id]); + echo json_encode(['ok'=>true,'payment_id'=>$pid,'amount'=>$balance]); + } else { + $errCode = $resp['errors'][0]['code'] ?? ''; + $errMsg = match($errCode) { + 'CARD_DECLINED','CARD_DECLINED_VERIFICATION_REQUIRED' => 'Card was declined.', + 'INSUFFICIENT_FUNDS' => 'Insufficient funds.', + default => $resp['errors'][0]['detail'] ?? 'Charge failed.', + }; + echo json_encode(['error'=>$errMsg]); + } + exit; + } + + if ($action === 'update_card') { + $id = (int)($_POST['id'] ?? 0); + $nonce = trim($_POST['nonce'] ?? ''); + if (!$id || !$nonce) { echo json_encode(['error'=>'Missing data']); exit; } + $stmt = db()->prepare("SELECT * FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + if (!$b) { echo json_encode(['error'=>'Not found']); exit; } + + // Disable old card if exists + $oldCardId = $b['square_card_id'] ?? ''; + if ($oldCardId) squareApi('POST', "/cards/{$oldCardId}/disable"); + + // Get or create Square customer + $customerId = $b['square_customer_id'] ?? ''; + if (!$customerId) { + $custResp = squareApi('POST', '/customers', [ + 'idempotency_key' => $b['booking_ref'] . '-cust2', + 'given_name' => $b['name'], + 'email_address' => $b['email'], + 'phone_number' => $b['phone'] ?: null, + 'reference_id' => $b['booking_ref'], + ]); + $customerId = $custResp['customer']['id'] ?? null; + } + + // Create new card on file + $cardBody = ['idempotency_key'=>$b['booking_ref'].'-card-'.time(),'source_id'=>$nonce,'card'=>['cardholder_name'=>$b['name']]]; + if ($customerId) $cardBody['card']['customer_id'] = $customerId; + $cardResp = squareApi('POST', '/cards', $cardBody); + $newCardId = $cardResp['card']['id'] ?? null; + $last4 = $cardResp['card']['last_4'] ?? null; + $brand = $cardResp['card']['card_brand'] ?? null; + if (!$newCardId) { + echo json_encode(['error' => $cardResp['errors'][0]['detail'] ?? 'Could not save card']); exit; + } + db()->prepare("UPDATE bookings SET square_customer_id=?, square_card_id=?, card_last4=?, card_brand=? WHERE id=?") + ->execute([$customerId, $newCardId, $last4, $brand, $id]); + echo json_encode(['ok'=>true,'last4'=>$last4,'brand'=>$brand]); + exit; + } + + if ($action === 'mark_returned') { + $id = (int)($_POST['id'] ?? 0); + if (!$id) { echo json_encode(['error'=>'Invalid']); exit; } + $stmt = db()->prepare("SELECT * FROM bookings WHERE id=?"); + $stmt->execute([$id]); + $b = $stmt->fetch(); + if (!$b) { echo json_encode(['error'=>'Not found']); exit; } + // Mark returned, set completed, wipe card data + db()->prepare("UPDATE bookings SET slingshot_returned=1, returned_at=NOW(), status='completed', + square_card_id=NULL, card_last4=NULL, card_brand=NULL, square_customer_id=NULL + WHERE id=?") + ->execute([$id]); + echo json_encode(['ok'=>true]); + exit; + } + + if ($action === 'block_date') { + $date = $_POST['date'] ?? ''; + $reason = substr($_POST['reason'] ?? '', 0, 200); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + db()->prepare("INSERT IGNORE INTO blocked_dates (block_date, reason) VALUES (?,?)")->execute([$date, $reason]); + $newId = (int)db()->lastInsertId(); + echo json_encode(['ok'=>true, 'id'=>$newId, 'date'=>$date, 'reason'=>$reason]); + } else { echo json_encode(['error'=>'Invalid date']); } + exit; + } + if ($action === 'unblock_date') { + $id = (int)($_POST['id'] ?? 0); + db()->prepare("DELETE FROM blocked_dates WHERE id=?")->execute([$id]); + echo json_encode(['ok'=>true]); + exit; + } + + if ($action === 'customer_save') { + $cid = (int)($_POST['id'] ?? 0); + $name = substr(trim($_POST['name'] ?? ''), 0, 150); + $email = trim($_POST['email'] ?? ''); + $phone = substr(trim($_POST['phone'] ?? ''), 0, 30); + $dob = preg_match('/^\d{4}-\d{2}-\d{2}$/', $_POST['dob'] ?? '') ? $_POST['dob'] : null; + $addr = substr(trim($_POST['address'] ?? ''), 0, 500); + $notes = substr(trim($_POST['notes'] ?? ''), 0, 1000); + $active = (int)($_POST['is_active'] ?? 1); + if (!$name || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + echo json_encode(['error' => 'Name and valid email are required']); exit; + } + if ($cid) { + db()->prepare("UPDATE pcs_customers SET name=?,email=?,phone=?,dob=?,address=?,notes=?,is_active=? WHERE id=?") + ->execute([$name,$email,$phone,$dob,$addr,$notes,$active,$cid]); + } else { + db()->prepare("INSERT INTO pcs_customers (name,email,phone,dob,address,notes,is_active) VALUES (?,?,?,?,?,?,?)") + ->execute([$name,$email,$phone,$dob,$addr,$notes,1]); + $cid = (int)db()->lastInsertId(); + } + echo json_encode(['ok'=>true,'id'=>$cid]); exit; + } + + if ($action === 'customer_delete') { + $cid = (int)($_POST['id'] ?? 0); + if ($cid) db()->prepare("DELETE FROM pcs_customers WHERE id=?")->execute([$cid]); + echo json_encode(['ok'=>true]); exit; + } + + exit; +} + +// ── Login page ───────────────────────────────────────────────────────────────── +if (!$authed) { ?> + + + + +Admin Login — Parker County Slingshot Rentals + + + +
+

Parker Admin

+

Slingshot Rentals Management

+ +

Invalid username or password.

+ +
+ + + + + + +
+
+ + +query("SELECT * FROM bookings ORDER BY rental_date ASC, created_at DESC")->fetchAll(); +$blocked = db()->query("SELECT * FROM blocked_dates ORDER BY block_date ASC")->fetchAll(); +$customers = db()->query(" + SELECT c.* + FROM pcs_customers c ORDER BY c.created_at DESC +")->fetchAll(); +// Group bookings by customer email in PHP (avoids cross-table collation issues) +$bookingsByEmail = []; +foreach ($bookings as $_b) { + $bookingsByEmail[strtolower(trim($_b['email']))][] = $_b; +} + +$stats = db()->query(" + SELECT + COUNT(*) AS total, + SUM(status='pending') AS pending, + SUM(status='confirmed') AS confirmed, + SUM(status='completed') AS completed, + SUM(status='cancelled') AS cancelled, + SUM(CASE WHEN status IN ('confirmed','completed') THEN amount ELSE 0 END) AS revenue, + SUM(waiver_signed) AS waivers_signed, + SUM(insurance_verified) AS insurance_done, + SUM(deposit_received) AS deposits_done + FROM bookings +")->fetch(); +?> + + + + +Admin — Parker County Slingshot Rentals + + + +
+

Parker County Slingshot — Admin

+ Sign Out +
+
+ + +
+
Total Bookings
+
Pending
+
Confirmed
+
Completed
+
$
Revenue
+
Waivers Signed
+
Insurance OK
+
Deposits Rcvd
+
+ + +
+
+

Bookings

+
+ + + + + +
+
+ + +
No bookings found.
+ +
+ + + + + + + + + + + + + + + $b['package']]; + + // Determine each step's state + $stepConfirmed = in_array($b['status'], ['confirmed','completed']); + $stepWaiver = (bool)$b['waiver_signed']; + $stepInsurance = (bool)$b['insurance_verified']; + $stepDeposit = (bool)$b['deposit_received']; + $stepLicense = (bool)$b['license_verified']; + $stepHelmet = (bool)$b['helmet_provided']; + $stepSafety = (bool)$b['safety_course']; + $stepOps = (bool)$b['operational_course']; + $stepReturned = (bool)$b['slingshot_returned']; + + // Dot colors: done=green, if cancelled skip all + $cancelled = $b['status'] === 'cancelled'; + $dotClass = function($done) use ($cancelled) { + if ($cancelled) return 'dot-skip'; + return $done ? 'dot-done' : 'dot-pending'; + }; + + $allDone = $stepConfirmed && $stepWaiver && $stepInsurance && $stepDeposit && $stepLicense && $stepHelmet && $stepSafety && $stepOps && $stepReturned; + $pendingCount = ($cancelled ? 0 : ( + (!$stepConfirmed?1:0)+(!$stepWaiver?1:0)+(!$stepInsurance?1:0)+(!$stepDeposit?1:0)+(!$stepLicense?1:0)+ + (!$stepHelmet?1:0)+(!$stepSafety?1:0)+(!$stepOps?1:0)+(!$stepReturned?1:0) + )); + ?> + + + + + + + + + + + + + + + + + +
CustomerRental DatePackageAmountStatusProgressSubmitted
+ + +
+ +
+ + +
+ +
$ + + +
+
+
+
+
+
+
+
+
+
+
+ + pending + + All done ✓ + +
+
+ + +
+

Customer

+
+
+
+ +
+ +
Package
+
+
$
+
Rental Date
+
+ +
Customer Message
+
+ + +
+
Admin Notes
+ + +
+
+ + +
+

Booking Flow

+
+ + +
+
+
+ Booking Submitted + +
+
+ + +
+
+ +
+
+ Booking Confirmed + + Confirmed — status: + Cancelled + Awaiting confirmation — change status above + + +
+
+ + +
+
+ +
+
+ Rental Waiver Signed + + + Signed by + on + N/A + Not yet signed + + + + + +
+
+ + +
+
+ +
+
+ Proof of Insurance Received + + + + +
+ +
+ +
+
+ + + +
+
+ +
+
+ Deposit & Balance — $ held · $ at pickup + + + •••• on file + + + + + N/A + Balance charged to card on file + Captured — $ charged + Refunded — $ + Hold voided — no charge + Hold active — card authorized, not yet charged + Card was declined — contact customer + Deposit marked received (manual) + Pending — no card on file yet + + + +
+ + + + + + + + + + + + + + +
+ +
+
+ + +
+
+ +
+
+ Driver's License Verified + + + + +
+ +
+ +
+
+ + + +
+ Pre-Departure Checklist +
+ + +
+
+ +
+
+ DOT Helmet Provided & Fits + + + +
+ +
+
+
+ + +
+
+ +
+
+ Safety Course Completed + + + +
+ +
+
+
+ + +
+
+ +
+
+ Slingshot Operational Course Done + + + +
+ +
+
+
+ + +
+
+ +
+
+ Slingshot Returned — Close Out + + + Returned — booking closed, card data wiped + + Mark when slingshot is safely returned — closes booking & wipes card data + + + +
+ +
+ +
+
+ + +
+
+ + +
+

Send Reminder Email

+

Select what the customer still needs to do, then send them a nudge email with clear instructions.

+ + +

Not applicable for cancelled bookings.

+ +
+

Include in Reminder

+
+ + + + +
+ + +
+ +
+ Waiver Link + https://parkerslingshotrentals.com/waiver.php?ref= +
+ +
+ +
+
+
+ +
+ + +
+

Block Dates

+
+
+ + +
+
+ + +
+ +
+
+ +

No dates blocked.

+ + +
+ + + +
+ + +
+
+ + +
+
+

Customers

+
+ + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
No customers yet.
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailPhoneBookingsStatusAdded
+ + + + +
+
+ +
+ +
+ + + + + + + + diff --git a/admin/login.php b/admin/login.php new file mode 100644 index 0000000..df2f014 --- /dev/null +++ b/admin/login.php @@ -0,0 +1,36 @@ +prepare("SELECT * FROM pcs_admins WHERE email=?"); + $admin->execute([$email]); + $a=$admin->fetch(); + if($a && password_verify($pass,$a['password_hash'])){ + $_SESSION['pcs_admin']=['id'=>$a['id'],'email'=>$a['email'],'name'=>$a['name']]; + header('Location:/admin/index.php');exit; + } + $error='Invalid credentials.'; +} +?> +Admin Login — Parker County Slingshot + + +
Admin Panel
+
+
+ +
+← Back to Site
diff --git a/admin/logout.php b/admin/logout.php new file mode 100644 index 0000000..ebe6e34 --- /dev/null +++ b/admin/logout.php @@ -0,0 +1,7 @@ +prepare("SELECT id FROM pcs_bookings WHERE id=?"); +$b->execute([$booking]); +if (!$b->fetch()) die('Booking not found.'); + +if ($type === 'license') { + $path = LICENSE_DIR . $file; +} elseif ($type === 'insurance') { + $path = INSURANCE_DIR . $file; +} else { + die('Invalid document type.'); +} + +if (!file_exists($path)) die('File not found.'); + +// Serve the file +$mime = mime_content_type($path); +header('Content-Type: ' . $mime); +header('Content-Disposition: inline; filename="' . $file . '"'); +header('Content-Length: ' . filesize($path)); +header('Cache-Control: no-store, no-cache'); +readfile($path); +exit; diff --git a/api/booking.php b/api/booking.php new file mode 100644 index 0000000..6935146 --- /dev/null +++ b/api/booking.php @@ -0,0 +1,136 @@ +false,'error'=>'Method not allowed'], 405); +} + +$name = trim($_POST['name'] ?? ''); +$email = trim($_POST['email'] ?? ''); +$phone = trim($_POST['phone'] ?? ''); +$dob = trim($_POST['dob'] ?? ''); +$method = trim($_POST['payment_method'] ?? 'cash'); +$notes = trim($_POST['notes'] ?? ''); +$startD = trim($_POST['start_date'] ?? ''); +$endD = trim($_POST['end_date'] ?? ''); + +// Validate required fields +if (!$name || !$email || !$phone || !$dob || !$startD || !$endD) { + jsonOut(['success'=>false,'error'=>'All fields are required.']); +} + +if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + jsonOut(['success'=>false,'error'=>'Invalid email address.']); +} + +// Validate dates +$start = DateTime::createFromFormat('Y-m-d', $startD); +$end = DateTime::createFromFormat('Y-m-d', $endD); +if (!$start || !$end || $end <= $start) { + jsonOut(['success'=>false,'error'=>'Invalid dates selected.']); +} + +$days = (int)$start->diff($end)->days; +$minDays = (int)(getSetting('min_days', 1)); +$maxDays = (int)(getSetting('max_days', 3)); + +if ($days < $minDays || $days > $maxDays) { + jsonOut(['success'=>false,'error'=>"Rental must be $minDays-$maxDays days."]); +} + +// Validate age (25+) +$dobDt = DateTime::createFromFormat('Y-m-d', $dob); +if (!$dobDt) jsonOut(['success'=>false,'error'=>'Invalid date of birth.']); +$age = (new DateTime())->diff($dobDt)->y; +if ($age < 25) { + jsonOut(['success'=>false,'error'=>'Must be 25 or older to rent.']); +} + +// Check availability +$conflict = db()->prepare(" + SELECT COUNT(*) FROM pcs_bookings + WHERE status IN ('approved','active','pending') + AND NOT (end_date <= :start OR start_date >= :end) +"); +$conflict->execute(['start'=>$startD,'end'=>$endD]); +if ($conflict->fetchColumn() > 0) { + jsonOut(['success'=>false,'error'=>'Those dates are no longer available.']); +} + +// Check blocked dates +$cursor = clone $start; +while ($cursor <= $end) { + $blocked = db()->prepare("SELECT id FROM pcs_blocked_dates WHERE blocked_date=?"); + $blocked->execute([$cursor->format('Y-m-d')]); + if ($blocked->fetch()) { + jsonOut(['success'=>false,'error'=>'One or more selected dates are unavailable.']); + } + $cursor->modify('+1 day'); +} + +// Handle file uploads +$licenseFile = null; +$insuranceFile = null; + +function saveUpload(string $field, string $dir, string $prefix): ?string { + if (empty($_FILES[$field]['tmp_name'])) return null; + $file = $_FILES[$field]; + $allowed = ['image/jpeg','image/png','image/gif','image/webp','application/pdf']; + if (!in_array($file['type'], $allowed)) return null; + if ($file['size'] > MAX_UPLOAD_BYTES) return null; + + if (!is_dir($dir)) mkdir($dir, 0755, true); + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $name = $prefix.'_'.time().'_'.bin2hex(random_bytes(4)).'.'.$ext; + $path = $dir.$name; + move_uploaded_file($file['tmp_name'], $path); + return $name; +} + +$licenseFile = saveUpload('license', LICENSE_DIR, 'lic'); +$insuranceFile = saveUpload('insurance', INSURANCE_DIR, 'ins'); + +if (!$licenseFile) { + jsonOut(['success'=>false,'error'=>"Driver's license upload failed. Check file type and size."]); +} +if (!$insuranceFile) { + jsonOut(['success'=>false,'error'=>'Insurance upload failed. Check file type and size.']); +} + +// Create or find customer +$customer = db()->prepare("SELECT id FROM pcs_customers WHERE email=?"); +$customer->execute([$email]); +$cust = $customer->fetch(); + +if ($cust) { + $custId = $cust['id']; + db()->prepare("UPDATE pcs_customers SET name=?,phone=?,dob=? WHERE id=?") + ->execute([$name,$phone,$dob,$custId]); +} else { + db()->prepare("INSERT INTO pcs_customers (email,name,phone,dob) VALUES (?,?,?,?)") + ->execute([$email,$name,$phone,$dob]); + $custId = db()->lastInsertId(); +} + +// Generate booking ref +$ref = 'PSR-'.strtoupper(bin2hex(random_bytes(3))); +$pricePerDay = (float)(getSetting('price_per_day', 150)); +$total = $days * $pricePerDay; + +// Get available fleet +$fleet = db()->query("SELECT id FROM pcs_fleet WHERE is_available=1 LIMIT 1")->fetch(); +$fleetId = $fleet['id'] ?? null; + +// Insert booking +db()->prepare("INSERT INTO pcs_bookings + (booking_ref,customer_id,fleet_id,start_date,end_date,days,price_per_day,total_amount, + payment_method,license_file,insurance_file,customer_notes) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)")->execute([ + $ref, $custId, $fleetId, $startD, $endD, $days, + $pricePerDay, $total, $method, $licenseFile, $insuranceFile, $notes +]); + +jsonOut(['success'=>true,'ref'=>$ref,'message'=>'Reservation submitted successfully.']); diff --git a/availability.php b/availability.php new file mode 100644 index 0000000..351a623 --- /dev/null +++ b/availability.php @@ -0,0 +1,47 @@ +prepare( + "SELECT rental_date, end_date FROM bookings + WHERE status IN ('pending','confirmed') + AND rental_date <= ? AND end_date >= ?" +); +$booked->execute([$end, $start]); + +$bookedDays = []; +foreach ($booked->fetchAll() as $row) { + $d = new DateTime($row['rental_date']); + $e = new DateTime($row['end_date']); + while ($d <= $e) { + $bookedDays[] = $d->format('Y-m-d'); + $d->modify('+1 day'); + } +} + +// Admin-blocked dates for this month +$blocked = db()->prepare( + "SELECT block_date FROM blocked_dates WHERE block_date BETWEEN ? AND ?" +); +$blocked->execute([$start, $end]); +foreach ($blocked->fetchAll() as $row) { + $bookedDays[] = $row['block_date']; +} + +echo json_encode([ + 'month' => $month, + 'year' => $year, + 'booked_dates' => array_values(array_unique($bookedDays)), +]); diff --git a/contact.php b/contact.php new file mode 100644 index 0000000..4160c95 --- /dev/null +++ b/contact.php @@ -0,0 +1,282 @@ +false,'error'=>'Method not allowed']); exit; } + +$input = json_decode(file_get_contents('php://input'), true) ?: $_POST; + +$name = trim(strip_tags($input['name'] ?? '')); +$email = trim(strip_tags($input['email'] ?? '')); +$phone = trim(strip_tags($input['phone'] ?? '')); +$package = trim(strip_tags($input['package'] ?? '')); +$date = trim(strip_tags($input['date'] ?? '')); +$message = trim(strip_tags($input['message'] ?? '')); +$squareToken = trim($input['square_token'] ?? ''); + +if (!$name || !$email || !$package || !$date) { + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'Name, email, package, and date are required.']); exit; +} +if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'Invalid email address.']); exit; +} +if (!isset(PACKAGES[$package])) { + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'Invalid package.']); exit; +} +if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || strtotime($date) < strtotime('today')) { + http_response_code(400); + echo json_encode(['success'=>false,'error'=>'Invalid or past date.']); exit; +} + +$pkg = PACKAGES[$package]; +$rentalDate = $date; +$endDate = date('Y-m-d', strtotime($date . ' +' . $pkg['days'] . ' days')); + +// Check availability +$conflict = db()->prepare( + "SELECT id FROM bookings + WHERE status IN ('pending','confirmed') + AND rental_date <= ? AND end_date >= ?" +); +$conflict->execute([$endDate, $rentalDate]); +if ($conflict->fetch()) { + echo json_encode(['success'=>false,'error'=>'Sorry, that date is already booked. Please choose another date.']); exit; +} +$blockedCheck = db()->prepare("SELECT id FROM blocked_dates WHERE block_date BETWEEN ? AND ?"); +$blockedCheck->execute([$rentalDate, $endDate]); +if ($blockedCheck->fetch()) { + echo json_encode(['success'=>false,'error'=>'That date is unavailable. Please choose another date.']); exit; +} + +$ref = generateRef(); +$dateLabel = date('F j, Y', strtotime($rentalDate)); +$pkgLabel = $pkg['label']; +$amountLabel = '$' . number_format($pkg['amount'], 2); +$depositLabel = '$' . number_format(DEPOSIT_AMOUNT, 2); +$balance = $pkg['amount'] - DEPOSIT_AMOUNT; +$balanceLabel = '$' . number_format($balance, 2); + +// ── Square: create customer + card on file + deposit hold ───────────────────── +$sqCustomerId = null; +$sqCardId = null; +$sqCardLast4 = null; +$sqCardBrand = null; +$sqPaymentId = null; +$sqPaymentStatus = null; +$paymentDeclined = false; +$paymentError = ''; + +if ($squareToken) { + // 1. Create Square Customer + $custResp = squareApi('POST', '/customers', [ + 'idempotency_key' => $ref . '-cust', + 'given_name' => $name, + 'email_address' => $email, + 'phone_number' => $phone ?: null, + 'reference_id' => $ref, + ]); + $sqCustomerId = $custResp['customer']['id'] ?? null; + + // 2. Create card on file + if ($sqCustomerId) { + $cardResp = squareApi('POST', '/cards', [ + 'idempotency_key' => $ref . '-card', + 'source_id' => $squareToken, + 'card' => [ + 'cardholder_name' => $name, + 'customer_id' => $sqCustomerId, + ], + ]); + $sqCardId = $cardResp['card']['id'] ?? null; + $sqCardLast4 = $cardResp['card']['last_4'] ?? null; + $sqCardBrand = $cardResp['card']['card_brand'] ?? null; + } + + // 3. Deposit hold — use card_id if card was saved, else fall back to nonce + $sourceId = $sqCardId ?: $squareToken; + $payBody = [ + 'source_id' => $sourceId, + 'idempotency_key' => $ref . '-dep-' . time(), + 'amount_money' => ['amount' => (int)(DEPOSIT_AMOUNT * 100), 'currency' => 'USD'], + 'autocomplete' => false, + 'location_id' => SQUARE_LOCATION_ID, + 'note' => "Deposit hold — booking {$ref}", + 'reference_id' => $ref, + 'buyer_email_address' => $email, + ]; + if ($sqCustomerId) $payBody['customer_id'] = $sqCustomerId; + $sqResp = squareApi('POST', '/payments', $payBody); + + if (!empty($sqResp['payment']['id'])) { + $sqPaymentId = $sqResp['payment']['id']; + $sqPaymentStatus = $sqResp['payment']['status']; // APPROVED + } else { + // Card declined or error + $paymentDeclined = true; + $errDetail = $sqResp['errors'][0]['detail'] ?? ($sqResp['errors'][0]['code'] ?? ''); + $errCode = $sqResp['errors'][0]['code'] ?? ''; + $paymentError = match($errCode) { + 'CARD_DECLINED', 'CARD_DECLINED_VERIFICATION_REQUIRED' => 'Your card was declined.', + 'INSUFFICIENT_FUNDS' => 'Insufficient funds on card.', + 'INVALID_CARD' => 'Card information is invalid.', + 'EXPIRED_CARD' => 'Your card has expired.', + 'CVV_FAILURE' => 'Card security code (CVV) did not match.', + 'ADDRESS_VERIFICATION_FAILURE' => 'Card address verification failed.', + default => 'Payment could not be processed. Please try a different card.', + }; + } +} + +// ── Always create the booking (admin sees declined attempts too) ────────────── +$stmt = db()->prepare( + "INSERT INTO bookings + (booking_ref, name, email, phone, package, rental_date, end_date, amount, notes, + square_customer_id, square_card_id, card_last4, card_brand, + square_payment_id, square_payment_status) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" +); +$stmt->execute([ + $ref, $name, $email, $phone, $package, $rentalDate, $endDate, $pkg['amount'], $message, + $sqCustomerId, $sqCardId, $sqCardLast4, $sqCardBrand, + $sqPaymentId, $sqPaymentStatus, +]); + +// ── Email templates ─────────────────────────────────────────────────────────── +$cardBadge = $sqCardLast4 + ? " ✓ " . htmlspecialchars($sqCardBrand ?? 'Card') . " •••• " . htmlspecialchars($sqCardLast4) . " on file" + : ''; + +if ($paymentDeclined) { + // ── Payment failed emails ──────────────────────────────────────────────── + $adminFailHtml = "
+
+

Payment Declined — Booking {$ref}

+
+
+

⚠ The customer's card was declined. Please contact them to arrange an alternate payment method.

+ + + + + + + + +
Ref{$ref}
Name" . htmlspecialchars($name) . "
Email" . htmlspecialchars($email) . "
Phone" . htmlspecialchars($phone ?: '—') . "
Package{$pkgLabel} — {$amountLabel}
Date{$dateLabel}
Error" . htmlspecialchars($paymentError) . "
+
+

Contact this customer to: take payment over the phone, ask them to try a different card online, or arrange alternative payment at pickup.

+
+
+
"; + + $custFailHtml = "
+
+

Parker County Slingshot Rentals

+
+
+

We Received Your Booking Request

+

Hey " . htmlspecialchars($name) . ", we received your reservation request (Ref: {$ref}) but unfortunately we weren't able to process your payment.

+
+

Payment Issue

+

" . htmlspecialchars($paymentError) . "

+
+

To secure your booking, please:

+ +

Your requested date is {$dateLabel} and we'll hold it for 24 hours while we sort out payment.

+

Ride on,
The Parker County Slingshot Team
" . ADMIN_PHONE . "

+
+
+

© " . date('Y') . " Parker County Slingshot Rentals — Weatherford, TX

+
+
"; + + sendEmail(ADMIN_EMAIL, 'Parker Slingshot Admin', "⚠ Payment Declined — {$ref}: {$name} ({$pkgLabel})", $adminFailHtml); + sendEmail($email, $name, "About Your Booking Request {$ref} — Parker County Slingshot Rentals", $custFailHtml); + + echo json_encode([ + 'success' => false, + 'payment_failed' => true, + 'ref' => $ref, + 'error' => $paymentError, + 'contact_phone' => ADMIN_PHONE, + 'contact_email' => ADMIN_EMAIL, + 'message' => "We received your request (Ref: {$ref}) but couldn't process your card. Please call or text us at " . ADMIN_PHONE . " or reply to the confirmation email we sent.", + ]); + exit; +} + +// ── Payment succeeded — send standard confirmation emails ───────────────────── +$adminHtml = "
+
+

New Booking Request — {$ref}

+
+
+ + + + + + + + + +
Ref{$ref}
Name" . htmlspecialchars($name) . "
Email" . htmlspecialchars($email) . "
Phone" . htmlspecialchars($phone ?: '—') . "
Package{$pkgLabel} — {$amountLabel}
Date{$dateLabel}
Deposit Hold{$depositLabel} (card held — not charged yet){$cardBadge}
Balance Due{$balanceLabel} at pickup
+ " . ($message ? "
" . nl2br(htmlspecialchars($message)) . "
" : "") . " +

✓ \$" . number_format(DEPOSIT_AMOUNT, 2) . " deposit hold authorized (Square — not yet captured)

+

Submitted " . date('F j, Y g:i A') . " CT

+
+
"; + +$confirmHtml = "
+
+

Parker County Slingshot Rentals

+
+
+

Booking Request Received!

+

Hey " . htmlspecialchars($name) . ", your request is in. We'll confirm availability and reach out within a few hours.

+
+

Booking Reference

+

{$ref}

+

Package: {$pkgLabel}

+

Requested Date: {$dateLabel}

+

Total: {$amountLabel}

+

Deposit (card hold today): {$depositLabel} ✓ Authorized{$cardBadge}

+

Balance due at pickup: {$balanceLabel}

+
+
+

Next Step: Sign Your Rental Agreement

+

Once your booking is confirmed you'll sign our digital waiver online — no printer needed. Your link:

+ Sign Rental Agreement → +
+

Questions? Call or text " . ADMIN_PHONE . " or reply to this email.

+

Ride on,
The Parker County Slingshot Team

+
+
+

© " . date('Y') . " Parker County Slingshot Rentals — Weatherford, TX

+
+
"; + +sendEmail(ADMIN_EMAIL, 'Parker Slingshot Admin', "New Booking {$ref}: {$name} — {$pkgLabel} on {$dateLabel}", $adminHtml); +sendEmail($email, $name, "Booking Request {$ref} — Parker County Slingshot Rentals", $confirmHtml); + +$msg = "Booking request received! Your reference is {$ref}. We'll be in touch shortly."; +if ($sqPaymentId) $msg .= " A \$" . number_format(DEPOSIT_AMOUNT, 2) . " refundable deposit hold has been placed on your card."; + +echo json_encode([ + 'success' => true, + 'ref' => $ref, + 'deposit_held' => (bool)$sqPaymentId, + 'square_payment_id' => $sqPaymentId, + 'message' => $msg, +]); diff --git a/db.php.example b/db.php.example new file mode 100644 index 0000000..7a8f286 --- /dev/null +++ b/db.php.example @@ -0,0 +1,91 @@ + ['label' => 'Half Day (4 hrs)', 'amount' => 99.00, 'days' => 0], + 'full-day' => ['label' => 'Full Day (8 hrs)', 'amount' => 169.00, 'days' => 0], + 'weekend' => ['label' => 'Weekend (48 hrs)', 'amount' => 299.00, 'days' => 1], +]); + +function db(): PDO { + static $pdo; + if (!$pdo) { + $pdo = new PDO( + 'mysql:host=' . PARKER_DB_HOST . ';dbname=' . PARKER_DB_NAME . ';charset=utf8mb4', + PARKER_DB_USER, PARKER_DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC] + ); + } + return $pdo; +} + +function squareApi(string $method, string $path, array $body = []): array { + $ch = curl_init('https://connect.squareup.com/v2' . $path); + $headers = [ + 'Authorization: Bearer ' . SQUARE_ACCESS_TOKEN, + 'Content-Type: application/json', + 'Square-Version: ' . SQUARE_VERSION, + ]; + $opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false]; + if ($method === 'POST') { + $opts[CURLOPT_POST] = true; + $opts[CURLOPT_POSTFIELDS] = $body ? json_encode($body) : '{}'; + } + curl_setopt_array($ch, $opts); + $resp = curl_exec($ch); + curl_close($ch); + return json_decode($resp ?: '{}', true); +} + +function generateRef(): string { + return 'PSR-' . strtoupper(substr(uniqid(), -6)); +} + +function sendEmail(string $to, string $toName, string $subject, string $html): bool { + if (strpos(MAILJET_API_KEY, 'YOUR_') !== false) return false; + $payload = json_encode([ + 'Messages' => [[ + 'From' => ['Email' => MAIL_FROM, 'Name' => MAIL_FROM_NAME], + 'To' => [['Email' => $to, 'Name' => $toName]], + 'Subject' => $subject, + 'HTMLPart' => $html, + ]], + ]); + $ch = curl_init('https://api.mailjet.com/v3.1/send'); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_USERPWD => MAILJET_API_KEY . ':' . MAILJET_SECRET_KEY, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => false, + ]); + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return $code === 200; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..ce7a40b --- /dev/null +++ b/index.html @@ -0,0 +1,1216 @@ + + + + + + Parker County Slingshot Rentals | Polaris Slingshot | Weatherford, TX + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

Built for the Thrill-Seeker

+

Everything you need for an unforgettable open-road experience — no hassle, just pure adrenaline.

+
+
+
🏎️
+

Polaris Slingshot SL

+

Our fleet features the latest Polaris Slingshot models — powerful, fast, and impossible to ignore on Texas roads.

+
+
+
🛡️
+

Full Coverage Insurance

+

Every rental includes comprehensive coverage. Drive with confidence knowing you're fully protected.

+
+
+
🗺️
+

Scenic Route Guides

+

We hand you a curated Texas route map — the best backroads, vistas, and stops around Parker County.

+
+
+
⛑️
+

Safety First

+

DOT-approved helmets included with every rental. Safety orientation for all drivers before you hit the road.

+
+
+
📞
+

Roadside Assistance

+

24/7 roadside support so you're never stranded. We've got your back from pickup to return.

+
+
+
+

Easy Booking

+

Simple online reservation, flexible pickup times, and transparent pricing. No hidden fees — ever.

+
+
+
+
+ + +
+
+ +

Pick Your Adventure

+

Transparent pricing. No surprise fees. Just you, the open road, and a machine that turns heads.

+
+
+

Half Day

+

$99

+

4 hours of freedom

+
    +
  • Polaris Slingshot SL
  • +
  • DOT helmets included
  • +
  • Safety orientation
  • +
  • Route map & guide
  • +
  • Proof of insurance required
  • +
  • Roadside assistance
  • +
+ Book Half Day +
+ +
+

Weekend

+

$299

+

48-hour getaway

+
    +
  • Polaris Slingshot SL
  • +
  • DOT helmets included
  • +
  • Safety orientation
  • +
  • Route map & guide
  • +
  • Proof of insurance required
  • +
  • 24/7 roadside assistance
  • +
+ Book Weekend +
+
+

Must be 25+ with valid driver's license. Security deposit required. Prices include tax.

+
+
+ + +
+
+ +

Ready in 5 Simple Steps

+

From booking to keys in hand — we make it effortless so you can focus on the fun.

+
+
+
1
+

Choose Your Package

+

Half day, full day, or weekend — pick the adventure that fits your schedule and budget.

+
+
+
2
+

Book & Submit

+

Fill out our simple booking form online or give us a call. We'll review your request right away.

+
+
+
3
+

Get Approved

+

We'll confirm availability and reach out within a few hours. Once approved, you're officially on the calendar.

+
+
+
4
+

Sign Your Waiver

+

You'll receive a link to our digital rental agreement. Sign it online in minutes — no printer needed.

+
+
+
5
+

Hit the Road

+

Arrive at pickup with your license and proof of insurance. Complete your safety briefing, grab your helmets, and go.

+
+
+
+
+ + +
+
+ +

Texas Roads Worth Driving

+

We know the best roads. Every rental comes with a recommended route guide — here's a taste.

+
+
+

The Parker County Loop

+

~45 miles • 1.5 hrs

+

Roll through Weatherford's historic downtown, Millsap, and back through the rolling Texas plains. The perfect intro ride.

+
+
+

Possum Kingdom Run

+

~80 miles • 2.5 hrs

+

Head northwest toward Mineral Wells and Possum Kingdom Lake. Hill Country scenery, lake views, and open highway.

+
+
+

Granbury & Glen Rose

+

~70 miles • 2 hrs

+

Cruise south to the charming Granbury square and dinosaur country in Glen Rose. Great for couples and history lovers.

+
+
+

DFW Sunset Cruise

+

~60 miles • 2 hrs

+

Head east toward the Fort Worth skyline at golden hour, loop through Azle and back. City lights in an open cockpit.

+
+
+
+
+ + +
+
+ +

Questions & Answers

+

Everything you need to know before you book.

+
+
+ Do I need a motorcycle license to rent a Polaris Slingshot in Texas? +

In Texas, a standard Class C driver's license is all you need. No motorcycle endorsement required. You must be 25 or older with a clean driving record to rent.

+
+
+ What's included in every rental? +

Every rental includes the Polaris Slingshot, DOT-approved helmets for driver and passenger, a safety orientation, a suggested scenic route map, and roadside assistance. Renters must provide proof of valid personal auto insurance. We carry a comprehensive fleet policy covering the vehicle.

+
+
+ How many people can ride in a Polaris Slingshot? +

The Polaris Slingshot is a two-seater — one driver and one passenger. Both experience the open-air thrill side by side in sports-car-style seats.

+
+
+ Is there a security deposit? +

Yes, a refundable security deposit is required at the time of pickup. The deposit is returned in full upon safe return of the vehicle with no damage.

+
+
+ Can I drive outside of Parker County? +

Yes — you can drive throughout the DFW area including Weatherford, Mineral Wells, Granbury, Azle, and Fort Worth. We'll note any restrictions in your rental agreement.

+
+
+ What if it rains? +

The Polaris Slingshot can be driven in light rain, but we recommend rescheduling in severe weather for your safety and comfort. We offer flexible rescheduling with 24-hour notice.

+
+
+ How do I cancel or reschedule? +

Cancel or reschedule free of charge up to 24 hours before your rental start time. Cancellations within 24 hours are subject to a 50% fee.

+
+
+
+
+ + +
+
+
+
+ +

Ready to Ride?

+

Send us your preferred date and package and we'll confirm availability within a few hours. Can't wait? Give us a call.

+
+ 📍 + Weatherford, TX 76086
(Exact pickup address provided at booking)
+
+
+ 📞 + (817) 555-0199 +
+ +
+ 🕐 + Mon–Fri: 9am–6pm • Sat–Sun: 8am–8pm +
+
+
+ +
+
+ +

Loading…

+ +
+
+
Checking availability…
+
+
+
Available
+
Selected
+ +
Unavailable
+
+

Click a date to select it — click again to deselect. Weekend package shows both days automatically.

+
+ +
+
+ + +
+ + + + + + + +
+
+

Deposit — $45 today

+ +
+

A $45 hold is placed on your card to secure your date — not charged until confirmed. Remaining balance is due at pickup.

+
+ + +
+ + +
+
+
+
+
+
+ + +
+ + +

© 2026 Parker County Slingshot Rentals — Weatherford, Texas. All rights reserved.

+

Polaris Slingshot® is a registered trademark of Polaris Inc. We are an independent rental operator.

+
+ + + + + diff --git a/waiver.php b/waiver.php new file mode 100644 index 0000000..109f735 --- /dev/null +++ b/waiver.php @@ -0,0 +1,365 @@ +prepare("SELECT * FROM bookings WHERE booking_ref = ?"); + $stmt->execute([$ref]); + $booking = $stmt->fetch(); + if (!$booking) { + $error = 'Booking reference not found. Please check your confirmation email.'; + } elseif ($booking['status'] === 'cancelled') { + $error = 'This booking has been cancelled. Please contact us if you have questions.'; + } elseif ($booking['waiver_signed']) { + $signed = true; + } +} + +// Handle submission +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $booking && !$signed) { + $sigName = trim(strip_tags($_POST['sig_name'] ?? '')); + $sigData = $_POST['sig_data'] ?? ''; // base64 canvas PNG + $checks = (array)($_POST['checks'] ?? []); + $required = ['age','license','insurance','rules','damage','waiver']; + + $missing = array_diff($required, $checks); + if (!$sigName) { + $error = 'Please type your full name to sign.'; + } elseif ($missing) { + $error = 'Please check all required boxes before signing.'; + } elseif (!$sigData || strpos($sigData, 'data:image/png;base64,') !== 0) { + $error = 'Please draw your signature in the box above.'; + } else { + $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? ''; + $ip = explode(',', $ip)[0]; + db()->prepare( + "UPDATE bookings SET waiver_signed=1, waiver_signed_at=NOW(), waiver_ip=?, waiver_name=?, waiver_sig=? WHERE booking_ref=?" + )->execute([trim($ip), $sigName, $sigData, $ref]); + + $pkg = PACKAGES[$booking['package']] ?? ['label'=>$booking['package']]; + $dateLabel = date('F j, Y', strtotime($booking['rental_date'])); + + $adminHtml = "
+
+

Waiver Signed — {$ref}

+
+
+

" . htmlspecialchars($booking['name']) . " signed the rental waiver for booking {$ref}.

+ + + + + + +
Package" . htmlspecialchars($pkg['label']) . "
Date{$dateLabel}
Signed by" . htmlspecialchars($sigName) . "
IP" . htmlspecialchars($ip) . "
Timestamp" . date('F j, Y g:i A') . " CT
+ Signature +
+
"; + + $custHtml = "
+
+

Parker County Slingshot Rentals

+
+
+

Waiver Signed — You're All Set!

+

Hey " . htmlspecialchars($booking['name']) . ", your rental agreement for booking {$ref} is signed and on file. See you on {$dateLabel}!

+

Remember to bring:

+
    +
  • Valid driver's license
  • +
  • Proof of personal auto insurance
  • +
+

Questions? Call or text (817) 555-0199.

+

Ride on,
The Parker County Slingshot Team

+
+
+

© " . date('Y') . " Parker County Slingshot Rentals — Weatherford, TX

+
+
"; + + sendEmail(ADMIN_EMAIL, 'Parker Slingshot Admin', "Waiver Signed: {$ref} — " . $booking['name'], $adminHtml); + sendEmail($booking['email'], $booking['name'], "Rental Agreement Signed — {$ref}", $custHtml); + + $signed = true; + } +} + +$pkgLabel = $booking ? (PACKAGES[$booking['package']]['label'] ?? $booking['package']) : ''; +$dateLabel = $booking ? date('F j, Y', strtotime($booking['rental_date'])) : ''; +?> + + + + + Rental Agreement — Parker County Slingshot Rentals + + + + + + + +
+ Parker County Slingshot Rentals + Rental Agreement +
+ +
+ + + +
+

Rental Agreement

+

Enter your booking reference from your confirmation email to access your rental agreement.

+
+
+ + + +
+
+ + + +
+
+
+

You're All Set!

+

Your rental agreement for booking is signed and on file. We'll see you on !

+

A confirmation was sent to .

+

Remember to bring:

+
    +
  • Valid driver's license
  • +
  • Proof of personal auto insurance
  • +
+
+ +
+ + + +
+ +
+

Rental Agreement

+

Please read and sign the agreement below for your upcoming rental.

+ +
+
Booking Ref
+
Name
+
Package
+
Rental Date
+
+ +

Rental Terms & Conditions

+

This Rental Agreement ("Agreement") is entered into between Parker County Slingshot Rentals ("Company") and the renter identified above ("Renter"). By signing below, Renter agrees to all terms stated herein.

+ +

Eligibility Requirements

+

Renter must be at least 25 years of age and hold a valid Class C driver's license (or equivalent) issued by a U.S. state or territory. Renter must not have any DUI/DWI convictions within the past 5 years.

+ +

Insurance Requirement

+

Renter is required to carry and provide proof of valid personal auto insurance at the time of vehicle pickup. The Company maintains a fleet insurance policy covering the vehicle; however, Renter's personal insurance is primary for liability arising from Renter's operation of the vehicle. Renter accepts financial responsibility for any deductible, damages, or losses not covered by either policy.

+ +

Vehicle Use & Rules

+

Renter agrees to operate the Polaris Slingshot in a safe, lawful manner and specifically agrees to:

+
    +
  • Obey all applicable traffic laws and speed limits
  • +
  • Never operate the vehicle under the influence of alcohol, drugs, or any impairing substance
  • +
  • Never allow an unauthorized third party to operate the vehicle
  • +
  • Wear the provided DOT-approved helmet at all times while operating the vehicle
  • +
  • Not take the vehicle off paved roads or outside the approved driving area
  • +
  • Return the vehicle at the agreed-upon time and location in the same condition it was received
  • +
+ +

Damage & Security Deposit

+

A refundable security deposit is required at pickup. Renter is financially responsible for any damage to the vehicle, including but not limited to collision damage, tire damage, interior damage, and any fines or citations incurred during the rental period. The Company reserves the right to apply the security deposit toward any such costs.

+ +

Assumption of Risk & Release of Liability

+

Renter acknowledges that operating a Polaris Slingshot involves inherent risks including, but not limited to, physical injury or death. Renter voluntarily assumes all such risks and, to the fullest extent permitted by Texas law, releases and holds harmless Parker County Slingshot Rentals, its owners, employees, and agents from any and all claims, damages, or liability arising out of Renter's use of the vehicle.

+ +

Cancellation Policy

+

Cancellations made more than 24 hours before the rental start time are fully refunded. Cancellations within 24 hours of the rental start time are subject to a 50% cancellation fee.

+
+ +
+ +
+

Acknowledgments

+

Check each box to confirm you have read and agree to that section.

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Your Signature

+ + + + +

Draw Your Signature

+
+ + +
+

Use your mouse or finger to sign in the box above.

+ +

+ By clicking "Sign & Submit" below, you confirm that you have read this entire Rental Agreement, that all acknowledgments above are checked, and that your typed name and drawn signature constitute a legally binding electronic signature under the ESIGN Act and Texas law. Signed: . +

+ + +
+
+ + + +
+ + + +