WebOrbiton
v1.0.0.0

StocketBase

485 lines · 27.2 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/user-auth.php';
  8. require_once __DIR__ . '/includes/csrf.php';
  9. require_once __DIR__ . '/includes/language.php';
  10. require_once __DIR__ . '/includes/site-front.php';
  11. require_once __DIR__ . '/includes/cart.php';
  12. require_once __DIR__ . '/includes/currency.php';
  13. ​
  14. if (is_file(__DIR__ . '/includes/payments/stripe-client.php')) {
  15. require_once __DIR__ . '/includes/payments/stripe-client.php';
  16. }
  17. if (is_file(__DIR__ . '/includes/payments/polar-client.php')) {
  18. require_once __DIR__ . '/includes/payments/polar-client.php';
  19. }
  20. ​
  21. UserAuth::boot();
  22. Cart::boot();
  23. ​
  24. $db = Database::site();
  25. $usersDb = Database::users();
  26. $settings = SiteFront::settings();
  27. ​
  28. function checkoutBaseUrl(): string
  29. {
  30. return rtrim((string) Config::get('APP_URL', ''), '/') . Config::get('APP_BASE_PATH', '');
  31. }
  32. ​
  33. function checkoutGenerateOrderNumber(PDO $usersDb): string
  34. {
  35. do {
  36. $candidate = 'ORD-' . date('Ymd') . '-' . strtoupper(bin2hex(random_bytes(3)));
  37. $statement = $usersDb->prepare('SELECT id FROM orders WHERE order_number = :n LIMIT 1');
  38. $statement->execute(['n' => $candidate]);
  39. } while ($statement->fetch());
  40. ​
  41. return $candidate;
  42. }
  43. ​
  44. $isSubmit = $_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'submit_order';
  45. $requestProductId = (int) ($_REQUEST['product_id'] ?? 0);
  46. $mode = $requestProductId > 0 ? 'single' : 'cart';
  47. $provider = (string) ($_REQUEST['provider'] ?? $settings['payment_provider']);
  48. if (!in_array($provider, ['stripe', 'polar'], true)) {
  49. $provider = 'stripe';
  50. }
  51. if ($provider === 'polar' && ($settings['store_category'] ?? 'general') !== 'saas') {
  52. $provider = 'stripe';
  53. }
  54. ​
  55. $errorMessage = null;
  56. $singleProduct = null;
  57. ​
  58. if ($isSubmit && !Csrf::verify($_POST['csrf_token'] ?? null)) {
  59. $isSubmit = false;
  60. $errorMessage = Language::get('form_security_failed', 'Security check failed, please try again.');
  61. }
  62. ​
  63. if ($mode === 'single') {
  64. $statement = $db->prepare("SELECT * FROM products WHERE id = :id AND status = 'published' LIMIT 1");
  65. $statement->execute(['id' => $requestProductId]);
  66. $singleProduct = $statement->fetch();
  67. ​
  68. if (!$singleProduct) {
  69. http_response_code(404);
  70. $pageTitle = 'Not found - ' . $settings['site_name'];
  71. require __DIR__ . '/includes/front-header.php';
  72. echo '<p>' . htmlspecialchars(Language::get('product_not_found', 'Product not found.')) . '</p>';
  73. require __DIR__ . '/includes/front-footer.php';
  74. exit;
  75. }
  76. ​
  77. $singleIsAllowed = ($settings['pricing_model'] ?? 'per_product') === 'subscription' || $provider === 'polar';
  78. if (!$singleIsAllowed) {
  79. header('Location: ' . SiteFront::productUrl((string) $singleProduct['slug']));
  80. exit;
  81. }
  82. } elseif (Cart::isEmpty()) {
  83. header('Location: cart.php');
  84. exit;
  85. }
  86. ​
  87. $loggedInCustomer = UserAuth::user();
  88. ​
  89. if ($loggedInCustomer === null && ($mode === 'single' || ($settings['require_account_to_purchase'] ?? '0') === '1')) {
  90. $checkoutReturnTo = 'checkout.php';
  91. if ($mode === 'single') {
  92. $checkoutReturnTo .= '?product_id=' . $requestProductId . ($provider === 'polar' ? '&provider=polar' : '');
  93. }
  94. header('Location: user-login.php?redirect=' . urlencode($checkoutReturnTo));
  95. exit;
  96. }
  97. ​
  98. $checkoutRequiresShipping = $mode === 'cart' && Cart::requiresShipping();
  99. ​
  100. if ($isSubmit && $mode === 'single') {
  101. $customerEmail = $loggedInCustomer['email'] ?? trim((string) ($_POST['email'] ?? ''));
  102. if ($customerEmail === '' || !filter_var($customerEmail, FILTER_VALIDATE_EMAIL)) {
  103. $errorMessage = Language::get('checkout_error_email', 'Please provide a valid email address.');
  104. } else {
  105. $productCurrency = (string) ($singleProduct['currency'] ?: $settings['store_currency']);
  106. $billingInterval = (string) ($singleProduct['billing_interval'] ?: 'month');
  107. $successUrl = checkoutBaseUrl() . '/order-confirmation.php?session_id={CHECKOUT_SESSION_ID}';
  108. $cancelUrl = checkoutBaseUrl() . SiteFront::productUrl((string) $singleProduct['slug']);
  109. $metadata = ['product_id' => (string) $singleProduct['id'], 'checkout_kind' => 'subscription'];
  110. if ($loggedInCustomer !== null) {
  111. $metadata['user_account_id'] = (string) $loggedInCustomer['id'];
  112. }
  113. ​
  114. if ($provider === 'polar') {
  115. if (!class_exists('PolarClient')) {
  116. $errorMessage = Language::get('checkout_error_polar_config', 'Polar checkout is not configured yet. Please try again later.');
  117. } else {
  118. $polarProductId = (string) ($singleProduct['polar_product_id'] ?? '');
  119. if ($polarProductId === '') {
  120. $errorMessage = Language::get('checkout_error_polar_product', 'This product is not linked to a Polar plan yet.');
  121. } else {
  122. $session = (new PolarClient())->createCheckoutSession($polarProductId, $successUrl, $customerEmail, $metadata);
  123. if (is_array($session) && !empty($session['url'])) {
  124. header('Location: ' . $session['url']);
  125. exit;
  126. }
  127. $errorMessage = Language::get('checkout_error_polar_start', 'Could not start Polar checkout. Please try again.');
  128. }
  129. }
  130. } else {
  131. if (!class_exists('StripeClient')) {
  132. $errorMessage = Language::get('checkout_error_payment_config', 'Payment is not configured yet. Please try again later.');
  133. } elseif (empty($singleProduct['price_cents'])) {
  134. $errorMessage = Language::get('checkout_error_no_price', 'This product does not have a price configured.');
  135. } else {
  136. $isOneTime = $billingInterval === 'one_time';
  137. $lineItems = [[
  138. 'name' => (string) $singleProduct['title'],
  139. 'unit_amount_cents' => (int) $singleProduct['price_cents'],
  140. 'currency' => strtolower($productCurrency),
  141. 'quantity' => 1,
  142. 'recurring_interval' => $isOneTime ? null : ($billingInterval === 'year' ? 'year' : 'month'),
  143. ]];
  144. if ($isOneTime) {
  145. $metadata['checkout_kind'] = 'one_time';
  146. }
  147. ​
  148. $session = (new StripeClient())->createCheckoutSession($lineItems, $isOneTime ? 'payment' : 'subscription', $successUrl, $cancelUrl, $customerEmail, $metadata);
  149. if (is_array($session) && !empty($session['url'])) {
  150. header('Location: ' . $session['url']);
  151. exit;
  152. }
  153. $errorMessage = Language::get('checkout_error_stripe_start', 'Could not start the payment. Please try again.');
  154. }
  155. }
  156. }
  157. }
  158. ​
  159. $formValues = [
  160. 'email' => $loggedInCustomer['email'] ?? '',
  161. 'shipping_name' => '',
  162. 'shipping_address_line1' => '',
  163. 'shipping_address_line2' => '',
  164. 'shipping_city' => '',
  165. 'shipping_region' => '',
  166. 'shipping_postal_code' => '',
  167. 'shipping_country' => '',
  168. 'billing_same_as_shipping' => '1',
  169. 'billing_name' => '',
  170. 'billing_address_line1' => '',
  171. 'billing_address_line2' => '',
  172. 'billing_city' => '',
  173. 'billing_region' => '',
  174. 'billing_postal_code' => '',
  175. 'billing_country' => '',
  176. 'customer_note' => '',
  177. ];
  178. ​
  179. if ($isSubmit && $mode === 'cart') {
  180. foreach ($formValues as $field => $default) {
  181. $formValues[$field] = trim((string) ($_POST[$field] ?? $default));
  182. }
  183. $sameAsShipping = ($_POST['billing_same_as_shipping'] ?? '') === '1';
  184. ​
  185. $items = Cart::items();
  186. if (empty($items)) {
  187. header('Location: cart.php');
  188. exit;
  189. }
  190. ​
  191. $customerEmail = $loggedInCustomer['email'] ?? $formValues['email'];
  192. $itemCurrencies = array_unique(array_map(static fn(array $item): string => strtoupper((string) $item['currency']), $items));
  193. ​
  194. if (count($itemCurrencies) > 1) {
  195. $errorMessage = Language::get('checkout_error_currencies', 'Your cart contains products priced in different currencies. Please check out products in one currency at a time.');
  196. } elseif ($customerEmail === '' || !filter_var($customerEmail, FILTER_VALIDATE_EMAIL)) {
  197. $errorMessage = Language::get('checkout_error_email', 'Please provide a valid email address.');
  198. } elseif ($checkoutRequiresShipping && ($formValues['shipping_name'] === '' || $formValues['shipping_address_line1'] === '' || $formValues['shipping_city'] === '' || $formValues['shipping_postal_code'] === '' || $formValues['shipping_country'] === '')) {
  199. $errorMessage = Language::get('checkout_error_shipping', 'Please fill in all required shipping fields.');
  200. } else {
  201. $cartCurrency = Cart::currency();
  202. $subtotalCents = Cart::subtotalCents();
  203. ​
  204. $appliedCode = Cart::appliedDiscountCode();
  205. $discount = $appliedCode !== null ? Cart::findDiscount($appliedCode, $cartCurrency) : null;
  206. $discountCents = $discount !== null ? Cart::discountAmountCents($discount, $subtotalCents) : 0;
  207. ​
  208. $shippingCents = 0;
  209. $taxCents = 0;
  210. $totalCents = max(0, $subtotalCents - $discountCents + $shippingCents + $taxCents);
  211. ​
  212. $billingName = $sameAsShipping ? $formValues['shipping_name'] : $formValues['billing_name'];
  213. $billingLine1 = $sameAsShipping ? $formValues['shipping_address_line1'] : $formValues['billing_address_line1'];
  214. $billingLine2 = $sameAsShipping ? $formValues['shipping_address_line2'] : $formValues['billing_address_line2'];
  215. $billingCity = $sameAsShipping ? $formValues['shipping_city'] : $formValues['billing_city'];
  216. $billingRegion = $sameAsShipping ? $formValues['shipping_region'] : $formValues['billing_region'];
  217. $billingPostal = $sameAsShipping ? $formValues['shipping_postal_code'] : $formValues['billing_postal_code'];
  218. $billingCountry = $sameAsShipping ? $formValues['shipping_country'] : $formValues['billing_country'];
  219. ​
  220. $orderNumber = checkoutGenerateOrderNumber($usersDb);
  221. ​
  222. try {
  223. $usersDb->beginTransaction();
  224. ​
  225. $insertOrder = $usersDb->prepare(
  226. "INSERT INTO orders (
  227. order_number, user_account_id, guest_email, status, provider, currency,
  228. subtotal_cents, discount_cents, shipping_cents, tax_cents, total_cents, discount_code,
  229. shipping_name, shipping_address_line1, shipping_address_line2, shipping_city, shipping_region, shipping_postal_code, shipping_country,
  230. billing_same_as_shipping, billing_name, billing_address_line1, billing_address_line2, billing_city, billing_region, billing_postal_code, billing_country,
  231. customer_note
  232. ) VALUES (
  233. :order_number, :user_account_id, :guest_email, 'pending', 'stripe', :currency,
  234. :subtotal_cents, :discount_cents, :shipping_cents, :tax_cents, :total_cents, :discount_code,
  235. :shipping_name, :shipping_address_line1, :shipping_address_line2, :shipping_city, :shipping_region, :shipping_postal_code, :shipping_country,
  236. :billing_same_as_shipping, :billing_name, :billing_address_line1, :billing_address_line2, :billing_city, :billing_region, :billing_postal_code, :billing_country,
  237. :customer_note
  238. )"
  239. );
  240. $insertOrder->execute([
  241. 'order_number' => $orderNumber,
  242. 'user_account_id' => $loggedInCustomer['id'] ?? null,
  243. 'guest_email' => $loggedInCustomer === null ? $customerEmail : null,
  244. 'currency' => $cartCurrency,
  245. 'subtotal_cents' => $subtotalCents,
  246. 'discount_cents' => $discountCents,
  247. 'shipping_cents' => $shippingCents,
  248. 'tax_cents' => $taxCents,
  249. 'total_cents' => $totalCents,
  250. 'discount_code' => $appliedCode,
  251. 'shipping_name' => $formValues['shipping_name'] ?: null,
  252. 'shipping_address_line1' => $formValues['shipping_address_line1'] ?: null,
  253. 'shipping_address_line2' => $formValues['shipping_address_line2'] ?: null,
  254. 'shipping_city' => $formValues['shipping_city'] ?: null,
  255. 'shipping_region' => $formValues['shipping_region'] ?: null,
  256. 'shipping_postal_code' => $formValues['shipping_postal_code'] ?: null,
  257. 'shipping_country' => $formValues['shipping_country'] !== '' ? strtoupper(substr($formValues['shipping_country'], 0, 2)) : null,
  258. 'billing_same_as_shipping' => $sameAsShipping ? 1 : 0,
  259. 'billing_name' => $billingName ?: null,
  260. 'billing_address_line1' => $billingLine1 ?: null,
  261. 'billing_address_line2' => $billingLine2 ?: null,
  262. 'billing_city' => $billingCity ?: null,
  263. 'billing_region' => $billingRegion ?: null,
  264. 'billing_postal_code' => $billingPostal ?: null,
  265. 'billing_country' => $billingCountry !== '' ? strtoupper(substr($billingCountry, 0, 2)) : null,
  266. 'customer_note' => $formValues['customer_note'] !== '' ? mb_substr($formValues['customer_note'], 0, 2000) : null,
  267. ]);
  268. $orderId = (int) $usersDb->lastInsertId();
  269. ​
  270. $insertItem = $usersDb->prepare(
  271. "INSERT INTO order_items (order_id, product_id, variant_id, product_title_snapshot, variant_name_snapshot, unit_price_cents, quantity, total_cents)
  272. VALUES (:order_id, :product_id, :variant_id, :title, :variant_name, :unit_price_cents, :quantity, :total_cents)"
  273. );
  274. foreach ($items as $item) {
  275. $insertItem->execute([
  276. 'order_id' => $orderId,
  277. 'product_id' => $item['product_id'],
  278. 'variant_id' => $item['variant_id'],
  279. 'title' => $item['title'],
  280. 'variant_name' => $item['variant_name'],
  281. 'unit_price_cents' => $item['unit_price_cents'],
  282. 'quantity' => $item['quantity'],
  283. 'total_cents' => $item['total_cents'],
  284. ]);
  285. }
  286. ​
  287. $insertHistory = $usersDb->prepare(
  288. "INSERT INTO order_status_history (order_id, status, note) VALUES (:order_id, 'pending', 'Order created, awaiting payment')"
  289. );
  290. $insertHistory->execute(['order_id' => $orderId]);
  291. ​
  292. $usersDb->commit();
  293. } catch (Throwable $exception) {
  294. if ($usersDb->inTransaction()) {
  295. $usersDb->rollBack();
  296. }
  297. error_log('StocketBase: could not create order: ' . $exception->getMessage());
  298. $orderId = null;
  299. }
  300. ​
  301. if (!isset($orderId) || $orderId === null) {
  302. $errorMessage = Language::get('checkout_error_order', 'Could not create your order. Please try again.');
  303. } else {
  304. if (!isset($_SESSION['checkout_order_ids']) || !is_array($_SESSION['checkout_order_ids'])) {
  305. $_SESSION['checkout_order_ids'] = [];
  306. }
  307. $_SESSION['checkout_order_ids'][] = $orderId;
  308. $_SESSION['checkout_order_ids'] = array_slice(array_unique($_SESSION['checkout_order_ids']), -20);
  309. ​
  310. $lineItems = [];
  311. foreach ($items as $item) {
  312. $name = $item['title'] . ($item['variant_name'] !== null ? ' — ' . $item['variant_name'] : '');
  313. $lineItems[] = [
  314. 'name' => $name,
  315. 'unit_amount_cents' => (int) $item['unit_price_cents'],
  316. 'currency' => strtolower($item['currency']),
  317. 'quantity' => (int) $item['quantity'],
  318. ];
  319. }
  320. ​
  321. $successUrl = checkoutBaseUrl() . '/order-confirmation.php?order_id=' . $orderId . '&session_id={CHECKOUT_SESSION_ID}';
  322. $cancelUrl = checkoutBaseUrl() . '/cart.php';
  323. $metadata = ['order_id' => (string) $orderId, 'checkout_kind' => 'order'];
  324. ​
  325. if (!class_exists('StripeClient')) {
  326. $errorMessage = Language::get('checkout_error_payment_config', 'Payment is not configured yet. Please try again later.');
  327. } else {
  328. $stripe = new StripeClient();
  329. $couponId = null;
  330. if ($discountCents > 0) {
  331. $couponId = $stripe->createOneTimeCoupon($discountCents, $cartCurrency, 'Discount ' . (string) $appliedCode);
  332. }
  333. ​
  334. if ($discountCents > 0 && $couponId === null) {
  335. $errorMessage = Language::get('checkout_error_discount', 'Could not apply your discount at payment. Please try again.');
  336. } else {
  337. $session = $stripe->createCheckoutSession($lineItems, 'payment', $successUrl, $cancelUrl, $customerEmail, $metadata, $couponId);
  338. if (is_array($session) && !empty($session['url'])) {
  339. $usersDb->prepare('UPDATE orders SET provider_checkout_id = :checkout_id WHERE id = :id')
  340. ->execute(['checkout_id' => (string) ($session['id'] ?? ''), 'id' => $orderId]);
  341. header('Location: ' . $session['url']);
  342. exit;
  343. }
  344. $errorMessage = Language::get('checkout_error_stripe_start', 'Could not start the payment. Please try again.');
  345. }
  346. }
  347. ​
  348. if ($errorMessage !== null) {
  349. $usersDb->prepare("UPDATE orders SET status = 'failed' WHERE id = :id AND status = 'pending'")
  350. ->execute(['id' => $orderId]);
  351. }
  352. }
  353. }
  354. }
  355. ​
  356. $pageTitle = Language::get('checkout_title', 'Checkout') . ' - ' . $settings['site_name'];
  357. $pageDescription = '';
  358. ​
  359. require __DIR__ . '/includes/front-header.php';
  360. ​
  361. ?>
  362. <h1 class="article-title"><?= htmlspecialchars(Language::get('checkout_title', 'Checkout')) ?></h1>
  363. ​
  364. <?php foreach (SiteFront::activeAds('checkout') as $checkoutAd): ?>
  365. <?= SiteFront::renderAd($checkoutAd) ?>
  366. <?php endforeach; ?>
  367. ​
  368. <?php if ($errorMessage !== null): ?>
  369. <p style="color: var(--danger);"><?= htmlspecialchars($errorMessage) ?></p>
  370. <?php endif; ?>
  371. ​
  372. <?php if ($mode === 'single'): ?>
  373. <?php
  374. $productCurrency = (string) ($singleProduct['currency'] ?: $settings['store_currency']);
  375. $billingInterval = (string) ($singleProduct['billing_interval'] ?: 'month');
  376. ?>
  377. <div class="checkout-summary">
  378. <h2><?= htmlspecialchars($singleProduct['title']) ?></h2>
  379. <p>
  380. <?= htmlspecialchars(Currency::format((int) $singleProduct['price_cents'], $productCurrency)) ?>
  381. / <?= htmlspecialchars($billingInterval === 'year' ? Language::get('subscription_per_year', 'year') : Language::get('subscription_per_month', 'month')) ?>
  382. <?= $provider === 'polar' ? ' · Polar' : ' · Stripe' ?>
  383. </p>
  384. </div>
  385. ​
  386. <form method="post" class="checkout-form">
  387. <?= Csrf::field() ?>
  388. <input type="hidden" name="action" value="submit_order">
  389. <input type="hidden" name="product_id" value="<?= (int) $singleProduct['id'] ?>">
  390. <input type="hidden" name="provider" value="<?= htmlspecialchars($provider, ENT_QUOTES) ?>">
  391. ​
  392. <?php if ($loggedInCustomer === null): ?>
  393. <label for="email"><?= htmlspecialchars(Language::get('checkout_email_label', 'Email address')) ?></label>
  394. <input type="email" name="email" id="email" required value="<?= htmlspecialchars($formValues['email']) ?>">
  395. <?php endif; ?>
  396. ​
  397. <button type="submit" class="unlock-btn" style="border:none;cursor:pointer;margin-top:16px;">
  398. <?= $provider === 'polar' ? htmlspecialchars(Language::get('product_subscribe_polar_button', 'Subscribe via Polar')) : htmlspecialchars(Language::get('product_subscribe_button', 'Subscribe')) ?>
  399. </button>
  400. </form>
  401. <?php else: ?>
  402. <?php
  403. $items = Cart::items();
  404. $cartCurrency = Cart::currency();
  405. $subtotalCents = Cart::subtotalCents();
  406. $appliedCode = Cart::appliedDiscountCode();
  407. $discount = $appliedCode !== null ? Cart::findDiscount($appliedCode, $cartCurrency) : null;
  408. $discountCents = $discount !== null ? Cart::discountAmountCents($discount, $subtotalCents) : 0;
  409. $totalCents = max(0, $subtotalCents - $discountCents);
  410. ?>
  411. <div class="checkout-summary">
  412. <ul>
  413. <?php foreach ($items as $item): ?>
  414. <li><?= htmlspecialchars($item['title']) ?><?= $item['variant_name'] !== null ? ' — ' . htmlspecialchars($item['variant_name']) : '' ?> &times; <?= (int) $item['quantity'] ?> — <?= htmlspecialchars(Currency::format($item['total_cents'], $item['currency'])) ?></li>
  415. <?php endforeach; ?>
  416. </ul>
  417. <div class="cart-summary-row"><span><?= htmlspecialchars(Language::get('cart_subtotal', 'Subtotal')) ?></span><span><?= htmlspecialchars(Currency::format($subtotalCents, $cartCurrency)) ?></span></div>
  418. <?php if ($discountCents > 0): ?>
  419. <div class="cart-summary-row"><span><?= htmlspecialchars(Language::get('cart_discount', 'Discount')) ?></span><span>-<?= htmlspecialchars(Currency::format($discountCents, $cartCurrency)) ?></span></div>
  420. <?php endif; ?>
  421. <div class="cart-summary-row cart-summary-total"><span><?= htmlspecialchars(Language::get('cart_total', 'Total')) ?></span><span><?= htmlspecialchars(Currency::format($totalCents, $cartCurrency)) ?></span></div>
  422. <p class="cart-tax-note"><?= htmlspecialchars(Language::get('checkout_tax_note', 'Tax is not calculated in this version.')) ?></p>
  423. </div>
  424. ​
  425. <form method="post" class="checkout-form">
  426. <?= Csrf::field() ?>
  427. <input type="hidden" name="action" value="submit_order">
  428. ​
  429. <?php if ($loggedInCustomer === null): ?>
  430. <label for="email"><?= htmlspecialchars(Language::get('checkout_email_label', 'Email address')) ?></label>
  431. <input type="email" name="email" id="email" required value="<?= htmlspecialchars($formValues['email']) ?>">
  432. <?php endif; ?>
  433. ​
  434. <?php if ($checkoutRequiresShipping): ?>
  435. <h2><?= htmlspecialchars(Language::get('checkout_shipping_title', 'Shipping address')) ?></h2>
  436. <label for="shipping_name"><?= htmlspecialchars(Language::get('checkout_full_name', 'Full name')) ?></label>
  437. <input type="text" name="shipping_name" id="shipping_name" required value="<?= htmlspecialchars($formValues['shipping_name']) ?>">
  438. <label for="shipping_address_line1"><?= htmlspecialchars(Language::get('checkout_address_line1', 'Address')) ?></label>
  439. <input type="text" name="shipping_address_line1" id="shipping_address_line1" required value="<?= htmlspecialchars($formValues['shipping_address_line1']) ?>">
  440. <label for="shipping_address_line2"><?= htmlspecialchars(Language::get('checkout_address_line2', 'Address line 2 (optional)')) ?></label>
  441. <input type="text" name="shipping_address_line2" id="shipping_address_line2" value="<?= htmlspecialchars($formValues['shipping_address_line2']) ?>">
  442. <label for="shipping_city"><?= htmlspecialchars(Language::get('checkout_city', 'City')) ?></label>
  443. <input type="text" name="shipping_city" id="shipping_city" required value="<?= htmlspecialchars($formValues['shipping_city']) ?>">
  444. <label for="shipping_region"><?= htmlspecialchars(Language::get('checkout_region', 'State / Region')) ?></label>
  445. <input type="text" name="shipping_region" id="shipping_region" value="<?= htmlspecialchars($formValues['shipping_region']) ?>">
  446. <label for="shipping_postal_code"><?= htmlspecialchars(Language::get('checkout_postal_code', 'Postal code')) ?></label>
  447. <input type="text" name="shipping_postal_code" id="shipping_postal_code" required value="<?= htmlspecialchars($formValues['shipping_postal_code']) ?>">
  448. <label for="shipping_country"><?= htmlspecialchars(Language::get('checkout_country', 'Country (2-letter code)')) ?></label>
  449. <input type="text" name="shipping_country" id="shipping_country" required maxlength="2" placeholder="US" value="<?= htmlspecialchars($formValues['shipping_country']) ?>">
  450. ​
  451. <label style="display:block;margin-top:12px;">
  452. <input type="checkbox" name="billing_same_as_shipping" value="1" id="billing_same_as_shipping" checked onchange="document.getElementById('billing-fields').style.display = this.checked ? 'none' : 'block';">
  453. <?= htmlspecialchars(Language::get('checkout_billing_same', 'Billing address same as shipping')) ?>
  454. </label>
  455. ​
  456. <div id="billing-fields" style="display:none;">
  457. <h2><?= htmlspecialchars(Language::get('checkout_billing_title', 'Billing address')) ?></h2>
  458. <label for="billing_name"><?= htmlspecialchars(Language::get('checkout_full_name', 'Full name')) ?></label>
  459. <input type="text" name="billing_name" id="billing_name" value="<?= htmlspecialchars($formValues['billing_name']) ?>">
  460. <label for="billing_address_line1"><?= htmlspecialchars(Language::get('checkout_address_line1', 'Address')) ?></label>
  461. <input type="text" name="billing_address_line1" id="billing_address_line1" value="<?= htmlspecialchars($formValues['billing_address_line1']) ?>">
  462. <label for="billing_address_line2"><?= htmlspecialchars(Language::get('checkout_address_line2', 'Address line 2 (optional)')) ?></label>
  463. <input type="text" name="billing_address_line2" id="billing_address_line2" value="<?= htmlspecialchars($formValues['billing_address_line2']) ?>">
  464. <label for="billing_city"><?= htmlspecialchars(Language::get('checkout_city', 'City')) ?></label>
  465. <input type="text" name="billing_city" id="billing_city" value="<?= htmlspecialchars($formValues['billing_city']) ?>">
  466. <label for="billing_region"><?= htmlspecialchars(Language::get('checkout_region', 'State / Region')) ?></label>
  467. <input type="text" name="billing_region" id="billing_region" value="<?= htmlspecialchars($formValues['billing_region']) ?>">
  468. <label for="billing_postal_code"><?= htmlspecialchars(Language::get('checkout_postal_code', 'Postal code')) ?></label>
  469. <input type="text" name="billing_postal_code" id="billing_postal_code" value="<?= htmlspecialchars($formValues['billing_postal_code']) ?>">
  470. <label for="billing_country"><?= htmlspecialchars(Language::get('checkout_country', 'Country (2-letter code)')) ?></label>
  471. <input type="text" name="billing_country" id="billing_country" maxlength="2" placeholder="US" value="<?= htmlspecialchars($formValues['billing_country']) ?>">
  472. </div>
  473. <?php else: ?>
  474. <p class="checkout-digital-note"><?= htmlspecialchars(Language::get('checkout_digital_only', 'Your order contains only digital products, so no shipping address is needed.')) ?></p>
  475. <?php endif; ?>
  476. ​
  477. <label for="customer_note"><?= htmlspecialchars(Language::get('checkout_note_label', 'Order note (optional)')) ?></label>
  478. <textarea name="customer_note" id="customer_note" rows="3"><?= htmlspecialchars($formValues['customer_note']) ?></textarea>
  479. ​
  480. <button type="submit" class="unlock-btn" style="border:none;cursor:pointer;margin-top:16px;"><?= htmlspecialchars(Language::get('checkout_pay_button', 'Continue to payment')) ?></button>
  481. </form>
  482. <?php endif; ?>
  483. ​
  484. <?php require __DIR__ . '/includes/front-footer.php'; ?>
  485. ​