|
@@ -0,0 +1,376 @@
|
|
|
|
|
+<script>
|
|
|
|
|
+ import { onMount } from "svelte";
|
|
|
|
|
+ import { PUBLIC_API_URL, PUBLIC_STRIPE_KEY } from "$env/static/public";
|
|
|
|
|
+ import { toast } from "$lib/toast.svelte.js";
|
|
|
|
|
+ import "$lib/global.css";
|
|
|
|
|
+ import DashboardNav from "../dashboard/DashboardNav.svelte";
|
|
|
|
|
+
|
|
|
|
|
+ const PRICE_PER_PACK = 1;
|
|
|
|
|
+ const MIN_PACKS = 5;
|
|
|
|
|
+
|
|
|
|
|
+ let quantity = $state(MIN_PACKS);
|
|
|
|
|
+ let stripe = $state(null);
|
|
|
|
|
+ let cardElement = $state(null);
|
|
|
|
|
+ let processing = $state(false);
|
|
|
|
|
+ let cardReady = $state(false);
|
|
|
|
|
+
|
|
|
|
|
+ function decrement() { if (quantity > MIN_PACKS) quantity--; }
|
|
|
|
|
+ function increment() { quantity++; }
|
|
|
|
|
+
|
|
|
|
|
+ onMount(async () => {
|
|
|
|
|
+ const { loadStripe } = await import("@stripe/stripe-js");
|
|
|
|
|
+ stripe = await loadStripe(PUBLIC_STRIPE_KEY);
|
|
|
|
|
+ const elements = stripe.elements();
|
|
|
|
|
+ cardElement = elements.create("card", {
|
|
|
|
|
+ style: {
|
|
|
|
|
+ base: {
|
|
|
|
|
+ color: "#e8e8f0",
|
|
|
|
|
+ fontFamily: "system-ui, -apple-system, sans-serif",
|
|
|
|
|
+ fontSize: "15px",
|
|
|
|
|
+ fontSmoothing: "antialiased",
|
|
|
|
|
+ "::placeholder": { color: "#44446688" },
|
|
|
|
|
+ iconColor: "#7a7aff"
|
|
|
|
|
+ },
|
|
|
|
|
+ invalid: {
|
|
|
|
|
+ color: "#e05555",
|
|
|
|
|
+ iconColor: "#e05555"
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ cardElement.mount("#card-element");
|
|
|
|
|
+ cardElement.on("ready", () => cardReady = true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const submit = async () => {
|
|
|
|
|
+ if (processing || !stripe || !cardElement) return;
|
|
|
|
|
+ processing = true;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const intentRes = await fetch(`${PUBLIC_API_URL}/user/payment-intent`, {
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
|
|
+ credentials: "include",
|
|
|
|
|
+ body: JSON.stringify({ token_packs: quantity })
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const intentData = await intentRes.json();
|
|
|
|
|
+ if (!intentRes.ok) {
|
|
|
|
|
+ toast(intentData.error?.message ?? "Failed to create payment.", "error");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { error, paymentIntent } = await stripe.confirmCardPayment(intentData.client_secret, {
|
|
|
|
|
+ payment_method: { card: cardElement }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (error) {
|
|
|
|
|
+ toast(error.message, "error");
|
|
|
|
|
+ } else if (paymentIntent.status === "succeeded") {
|
|
|
|
|
+ toast(`${quantity}M tokens added to your account!`);
|
|
|
|
|
+ quantity = MIN_PACKS;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ toast("Something went wrong. Please try again.", "error");
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ processing = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<div class="page">
|
|
|
|
|
+ <DashboardNav />
|
|
|
|
|
+
|
|
|
|
|
+ <main>
|
|
|
|
|
+ <div class="heading">
|
|
|
|
|
+ <a href="/dashboard" class="back">
|
|
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <path d="M19 12H5M12 5l-7 7 7 7"/>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Dashboard
|
|
|
|
|
+ </a>
|
|
|
|
|
+ <h1>Add Tokens</h1>
|
|
|
|
|
+ <p>Each token pack gives you 1 million tokens for $1. Minimum purchase is $5.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="checkout">
|
|
|
|
|
+ <div class="panel selector-panel">
|
|
|
|
|
+ <h2>How many packs?</h2>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="quantity-control">
|
|
|
|
|
+ <button class="qty-btn" onclick={decrement} disabled={quantity <= MIN_PACKS} aria-label="Decrease quantity">
|
|
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M5 12h14"/></svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <span class="qty-value">{quantity}</span>
|
|
|
|
|
+ <button class="qty-btn" onclick={increment} aria-label="Increase quantity">
|
|
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="breakdown">
|
|
|
|
|
+ <div class="breakdown-row">
|
|
|
|
|
+ <span>Token packs</span>
|
|
|
|
|
+ <span>{quantity}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="breakdown-row">
|
|
|
|
|
+ <span>Tokens added</span>
|
|
|
|
|
+ <span>{quantity}M</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="breakdown-row total">
|
|
|
|
|
+ <span>Total</span>
|
|
|
|
|
+ <span>${quantity}.00</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <p class="note">$1 per million tokens — no subscriptions, no expiry.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="panel card-panel">
|
|
|
|
|
+ <h2>Payment details</h2>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="card-field" class:ready={cardReady}>
|
|
|
|
|
+ <div id="card-element"></div>
|
|
|
|
|
+ {#if !cardReady}
|
|
|
|
|
+ <div class="card-loading">Loading secure payment form…</div>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <button class="pay-btn" onclick={submit} disabled={processing || !cardReady}>
|
|
|
|
|
+ {#if processing}
|
|
|
|
|
+ <span class="spinner"></span>
|
|
|
|
|
+ Processing…
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ Pay ${quantity}.00
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ <p class="secure-note">
|
|
|
|
|
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
|
|
|
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
|
|
|
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Secured by Stripe. Your card details never touch our servers.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </main>
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<style>
|
|
|
|
|
+ .page {
|
|
|
|
|
+ min-height: 100vh;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ background: #0b0b10;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ main {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ max-width: 820px;
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ margin: 0 auto;
|
|
|
|
|
+ padding: 3rem 2rem 6rem;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── Heading ── */
|
|
|
|
|
+ .heading {
|
|
|
|
|
+ margin-bottom: 2.5rem;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .back {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ font-size: 0.85rem;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ color: #6b6b7a;
|
|
|
|
|
+ text-decoration: none;
|
|
|
|
|
+ margin-bottom: 1.25rem;
|
|
|
|
|
+ transition: color 0.15s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .back:hover { color: #a1a1aa; }
|
|
|
|
|
+
|
|
|
|
|
+ h1 {
|
|
|
|
|
+ font-size: clamp(1.8rem, 4vw, 2.4rem);
|
|
|
|
|
+ font-weight: 800;
|
|
|
|
|
+ letter-spacing: -1.5px;
|
|
|
|
|
+ color: #e5e5ea;
|
|
|
|
|
+ margin-bottom: 0.5rem;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .heading p {
|
|
|
|
|
+ font-size: 0.95rem;
|
|
|
|
|
+ color: #6b6b7a;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── Checkout grid ── */
|
|
|
|
|
+ .checkout {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
|
|
+ gap: 1.25rem;
|
|
|
|
|
+ align-items: start;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .panel {
|
|
|
|
|
+ background: #0f0f18;
|
|
|
|
|
+ border: 1px solid #1e1e28;
|
|
|
|
|
+ border-radius: 16px;
|
|
|
|
|
+ padding: 2rem;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 1.5rem;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .panel h2 {
|
|
|
|
|
+ font-size: 1rem;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #a1a1aa;
|
|
|
|
|
+ letter-spacing: 0.3px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── Quantity control ── */
|
|
|
|
|
+ .quantity-control {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ background: #0b0b10;
|
|
|
|
|
+ border: 1px solid #1e1e28;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ padding: 0.5rem;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .qty-btn {
|
|
|
|
|
+ width: 40px;
|
|
|
|
|
+ height: 40px;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ background: #1a1a26;
|
|
|
|
|
+ color: #e5e5ea;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ transition: background 0.15s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .qty-btn:hover:not(:disabled) { background: #252535; }
|
|
|
|
|
+ .qty-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
|
|
|
+
|
|
|
|
|
+ .qty-value {
|
|
|
|
|
+ font-size: 1.75rem;
|
|
|
|
|
+ font-weight: 800;
|
|
|
|
|
+ color: #e5e5ea;
|
|
|
|
|
+ letter-spacing: -1px;
|
|
|
|
|
+ min-width: 3ch;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── Breakdown ── */
|
|
|
|
|
+ .breakdown {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 0.5rem;
|
|
|
|
|
+ border-top: 1px solid #1e1e28;
|
|
|
|
|
+ padding-top: 1.25rem;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .breakdown-row {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ font-size: 0.9rem;
|
|
|
|
|
+ color: #6b6b7a;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .breakdown-row span:last-child { color: #a1a1aa; }
|
|
|
|
|
+
|
|
|
|
|
+ .breakdown-row.total {
|
|
|
|
|
+ font-size: 1.05rem;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: #e5e5ea;
|
|
|
|
|
+ border-top: 1px solid #1e1e28;
|
|
|
|
|
+ padding-top: 0.75rem;
|
|
|
|
|
+ margin-top: 0.25rem;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .breakdown-row.total span:last-child {
|
|
|
|
|
+ color: #c084fc;
|
|
|
|
|
+ font-size: 1.15rem;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .note {
|
|
|
|
|
+ font-size: 0.8rem;
|
|
|
|
|
+ color: #44445a;
|
|
|
|
|
+ line-height: 1.5;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── Card element ── */
|
|
|
|
|
+ .card-field {
|
|
|
|
|
+ background: #0b0b10;
|
|
|
|
|
+ border: 1px solid #1e1e28;
|
|
|
|
|
+ border-radius: 10px;
|
|
|
|
|
+ padding: 0.875rem 1rem;
|
|
|
|
|
+ min-height: 48px;
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ transition: border-color 0.15s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .card-field.ready { border-color: #2e2e4a; }
|
|
|
|
|
+
|
|
|
|
|
+ .card-loading {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ inset: 0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ padding: 0 1rem;
|
|
|
|
|
+ font-size: 0.875rem;
|
|
|
|
|
+ color: #44446688;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* ── Pay button ── */
|
|
|
|
|
+ .pay-btn {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ padding: 0.8rem 1rem;
|
|
|
|
|
+ background: #c084fc;
|
|
|
|
|
+ color: #0b0b10;
|
|
|
|
|
+ font-size: 1rem;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-radius: 10px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ gap: 0.5rem;
|
|
|
|
|
+ transition: background 0.15s, transform 0.1s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .pay-btn:hover:not(:disabled) { background: #d8b4fe; }
|
|
|
|
|
+ .pay-btn:active:not(:disabled) { transform: scale(0.98); }
|
|
|
|
|
+ .pay-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
|
+
|
|
|
|
|
+ .spinner {
|
|
|
|
|
+ width: 16px;
|
|
|
|
|
+ height: 16px;
|
|
|
|
|
+ border: 2px solid rgba(11, 11, 16, 0.3);
|
|
|
|
|
+ border-top-color: #0b0b10;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ animation: spin 0.7s linear infinite;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
|
+
|
|
|
|
|
+ .secure-note {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ font-size: 0.78rem;
|
|
|
|
|
+ color: #44445a;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @media (max-width: 640px) {
|
|
|
|
|
+ .checkout { grid-template-columns: 1fr; }
|
|
|
|
|
+ main { padding: 2rem 1.25rem 4rem; }
|
|
|
|
|
+ }
|
|
|
|
|
+</style>
|