1
0

2 Commity 434dd907bd ... 6a4b412204

Autor SHA1 Správa Dátum
  Lee Morgan 6a4b412204 Create logout button 3 týždňov pred
  Lee Morgan 5574bc039a Create payment page to purchase tokens, untested 3 týždňov pred

+ 10 - 0
package-lock.json

@@ -8,6 +8,7 @@
 			"name": "chatrpg-app",
 			"version": "0.0.1",
 			"dependencies": {
+				"@stripe/stripe-js": "^9.8.0",
 				"dotenv": "^17.4.2",
 				"express": "^5.2.1"
 			},
@@ -428,6 +429,15 @@
 			"dev": true,
 			"license": "MIT"
 		},
+		"node_modules/@stripe/stripe-js": {
+			"version": "9.8.0",
+			"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.8.0.tgz",
+			"integrity": "sha512-DHJpol/98VKyojNSYmpkB5vOMnlf87hPe0wPxyaYTNiTMk5QjKMXDfSZLwGctYIXAgAWDFeRABc8lFAj0BELyw==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=12.16"
+			}
+		},
 		"node_modules/@sveltejs/acorn-typescript": {
 			"version": "1.0.10",
 			"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
 		"vite": "^8.0.16"
 	},
 	"dependencies": {
+		"@stripe/stripe-js": "^9.8.0",
 		"dotenv": "^17.4.2",
 		"express": "^5.2.1"
 	}

+ 1 - 9
src/routes/dashboard/+page.svelte

@@ -15,7 +15,7 @@
         </div>
 
         <div class="actions">
-            <a href="/dashboard/tokens" class="card card-tokens">
+            <a href="/tokens" class="card card-tokens">
                 <div class="card-icon">
                     <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
                         <circle cx="12" cy="12" r="10"/>
@@ -91,14 +91,6 @@
         pointer-events: none;
     }
 
-    .eyebrow {
-        font-size: 0.75rem;
-        font-weight: 600;
-        letter-spacing: 2px;
-        text-transform: uppercase;
-        color: #c084fc;
-        margin-bottom: 0.75rem;
-    }
 
     h1 {
         font-size: clamp(2.4rem, 6vw, 3.5rem);

+ 23 - 1
src/routes/dashboard/DashboardNav.svelte

@@ -1,5 +1,14 @@
 <script>
     import favicon from "$lib/assets/favicon.png";
+    import { PUBLIC_API_URL } from "$env/static/public";
+    import { goto } from "$app/navigation";
+
+    const logout = async () => {
+        await fetch(`${PUBLIC_API_URL}/user/logout`, {
+            credentials: "include"
+        });
+        goto("/");
+    };
 </script>
 
 <header>
@@ -8,8 +17,9 @@
         <span>ChatRPG</span>
     </a>
     <nav>
-        <a href="/dashboard/tokens" class="nav-link">Tokens</a>
+        <a href="/tokens" class="nav-link">Tokens</a>
         <a href="/dashboard/games" class="nav-link">Games</a>
+        <button class="nav-link logout" onclick={logout}>Log Out</button>
     </nav>
 </header>
 
@@ -59,4 +69,16 @@
         background: #1a1a24;
         color: #e5e5ea;
     }
+
+    .logout {
+        background: none;
+        border: none;
+        cursor: pointer;
+        font-family: inherit;
+    }
+
+    .logout:hover {
+        background: #1a1a24;
+        color: #e05555;
+    }
 </style>

+ 376 - 0
src/routes/tokens/+page.svelte

@@ -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 &mdash; 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>