Add Stripe donation flow

This commit is contained in:
hz
2026-07-19 22:26:33 -05:00
parent f12904bf73
commit dec5e74aa7
30 changed files with 1053 additions and 1 deletions
+12
View File
@@ -79,6 +79,18 @@ jobs:
--from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \
--dry-run=client -o yaml | kubectl apply -n step3-questions -f -
- name: Configure Stripe secrets
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
run: |
test -n "$STRIPE_SECRET_KEY"
test -n "$STRIPE_WEBHOOK_SECRET"
kubectl create secret generic medmind-stripe \
--from-literal=secret-key="$STRIPE_SECRET_KEY" \
--from-literal=webhook-secret="$STRIPE_WEBHOOK_SECRET" \
--dry-run=client -o yaml | kubectl apply -n step3-questions -f -
- name: Apply Questions deployment & service
run: |
kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions
+28
View File
@@ -0,0 +1,28 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class DonationTests
{
[Theory]
[InlineData(499)]
[InlineData(1_000_001)]
public void RejectsAmountsOutsideConfiguredRange(long amountCents) =>
Assert.NotNull(DonationValidator.ValidateAmount(amountCents));
[Theory]
[InlineData(500)]
[InlineData(1_000)]
[InlineData(1_000_000)]
public void AcceptsAmountsInsideConfiguredRange(long amountCents) =>
Assert.Null(DonationValidator.ValidateAmount(amountCents));
[Fact]
public void PublicStatusDoesNotExposeStripeOrUserIdentifiers()
{
Assert.Null(typeof(DonationStatusResponse).GetProperty(nameof(Donation.UserId)));
Assert.Null(typeof(DonationStatusResponse).GetProperty(nameof(Donation.StripeCheckoutSessionId)));
Assert.Null(typeof(DonationStatusResponse).GetProperty(nameof(Donation.StripePaymentIntentId)));
}
}
@@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using Stripe;
using Stripe.Checkout;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController, Route("api/donations")]
public sealed class DonationsController(
DonationStore donations,
IStripeDonationGateway stripe,
IConfiguration configuration,
ILogger<DonationsController> logger) : ControllerBase
{
[HttpPost("checkout"), Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> CreateCheckout(CreateDonationCheckoutRequest request, CancellationToken cancellationToken)
{
if (DonationValidator.ValidateAmount(request.AmountCents) is { } error) return BadRequest(error);
var donation = await donations.CreateAsync(UserId(), request.AmountCents);
try
{
var checkout = await stripe.CreateCheckoutAsync(donation.Id!, donation.AmountCents, cancellationToken);
await donations.AttachCheckoutSessionAsync(donation.Id!, checkout.SessionId);
return StatusCode(StatusCodes.Status201Created, new DonationCheckoutResponse
{
DonationId = donation.Id!, CheckoutUrl = checkout.CheckoutUrl, AmountCents = donation.AmountCents
});
}
catch (Exception exception)
{
await donations.MarkCheckoutCreationFailedAsync(donation.Id!, exception is StripeException ? "stripe_error" : "configuration_error");
logger.LogError(exception, "Unable to create Stripe Checkout Session for donation {DonationId}", donation.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Donation checkout unavailable",
detail: "The secure checkout could not be started. Please try again.");
}
}
[HttpGet("{id}"), Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> GetStatus(string id) =>
await donations.GetOwnedAsync(id.Trim(), UserId()) is { } donation
? Ok(DonationStatusResponse.FromModel(donation))
: NotFound();
[HttpPost("stripe-webhook"), AllowAnonymous]
public async Task<IActionResult> StripeWebhook(CancellationToken cancellationToken)
{
var webhookSecret = configuration["Stripe:WebhookSecret"];
if (string.IsNullOrWhiteSpace(webhookSecret)) return Problem(statusCode: 503, title: "Stripe webhook is not configured");
using var reader = new StreamReader(Request.Body);
var json = await reader.ReadToEndAsync(cancellationToken);
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], webhookSecret);
}
catch (StripeException exception)
{
logger.LogWarning(exception, "Rejected Stripe webhook with an invalid signature");
return BadRequest("Invalid Stripe signature");
}
if (stripeEvent.Data.Object is not Session session) return Ok();
var status = stripeEvent.Type switch
{
"checkout.session.completed" when session.PaymentStatus == "paid" => DonationStatus.Paid,
"checkout.session.async_payment_succeeded" => DonationStatus.Paid,
"checkout.session.async_payment_failed" => DonationStatus.Failed,
"checkout.session.expired" => DonationStatus.Expired,
_ => ""
};
if (status.Length == 0) return Ok();
var donationId = session.ClientReferenceId;
if (string.IsNullOrWhiteSpace(donationId) && session.Metadata.TryGetValue("donation_id", out var metadataId)) donationId = metadataId;
var applied = await donations.ApplyStripeStatusAsync(
donationId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "",
status, session.PaymentIntentId ?? "", stripeEvent.Id);
if (!applied) logger.LogWarning("Stripe event {EventId} did not match donation {DonationId}", stripeEvent.Id, donationId);
return Ok();
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? "";
}
+75
View File
@@ -0,0 +1,75 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class DonationStatus
{
public const string Pending = "pending";
public const string Paid = "paid";
public const string Failed = "failed";
public const string Expired = "expired";
}
public sealed class Donation
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public long AmountCents { get; set; }
public string Currency { get; set; } = "usd";
public string Status { get; set; } = DonationStatus.Pending;
[BsonIgnoreIfNull] public string? StripeCheckoutSessionId { get; set; }
public string StripePaymentIntentId { get; set; } = "";
public string LastStripeEventId { get; set; } = "";
public string FailureCode { get; set; } = "";
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? PaidUtc { get; set; }
}
public sealed class CreateDonationCheckoutRequest
{
public long AmountCents { get; set; }
}
public sealed class DonationCheckoutResponse
{
public string DonationId { get; set; } = "";
public string CheckoutUrl { get; set; } = "";
public long AmountCents { get; set; }
public string Currency { get; set; } = "usd";
public string Status { get; set; } = DonationStatus.Pending;
}
public sealed class DonationStatusResponse
{
public string DonationId { get; set; } = "";
public long AmountCents { get; set; }
public string Currency { get; set; } = "usd";
public string Status { get; set; } = DonationStatus.Pending;
public DateTime CreatedUtc { get; set; }
public DateTime? PaidUtc { get; set; }
public static DonationStatusResponse FromModel(Donation donation) => new()
{
DonationId = donation.Id ?? "",
AmountCents = donation.AmountCents,
Currency = donation.Currency,
Status = donation.Status,
CreatedUtc = donation.CreatedUtc,
PaidUtc = donation.PaidUtc
};
}
public static class DonationValidator
{
public const long MinimumAmountCents = 500;
public const long MaximumAmountCents = 1_000_000;
public static string? ValidateAmount(long amountCents) => amountCents switch
{
< MinimumAmountCents => "amountCents must be at least 500",
> MaximumAmountCents => "amountCents must be no more than 1000000",
_ => null
};
}
+2
View File
@@ -26,6 +26,8 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<DonationStore>();
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
+1
View File
@@ -10,6 +10,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
<PackageReference Include="Stripe.net" Version="52.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
@@ -0,0 +1,74 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class DonationStore
{
private readonly IMongoCollection<Donation> _donations;
public DonationStore(MongoDatabaseProvider mongo)
{
_donations = mongo.Database.GetCollection<Donation>("Donations");
_donations.Indexes.CreateMany([
new CreateIndexModel<Donation>(Builders<Donation>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
new CreateIndexModel<Donation>(Builders<Donation>.IndexKeys.Ascending(value => value.StripeCheckoutSessionId),
new CreateIndexOptions<Donation>
{
Unique = true,
PartialFilterExpression = Builders<Donation>.Filter.Type(value => value.StripeCheckoutSessionId, MongoDB.Bson.BsonType.String)
}),
new CreateIndexModel<Donation>(Builders<Donation>.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc))
]);
}
public async Task<Donation> CreateAsync(string userId, long amountCents)
{
var donation = new Donation { UserId = userId, AmountCents = amountCents };
await _donations.InsertOneAsync(donation);
return donation;
}
public async Task<Donation?> GetOwnedAsync(string id, string userId) =>
await _donations.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
public async Task AttachCheckoutSessionAsync(string id, string sessionId) =>
await _donations.UpdateOneAsync(
value => value.Id == id && value.Status == DonationStatus.Pending,
Builders<Donation>.Update.Set(value => value.StripeCheckoutSessionId, sessionId).Set(value => value.UpdatedUtc, DateTime.UtcNow));
public async Task MarkCheckoutCreationFailedAsync(string id, string failureCode) =>
await _donations.UpdateOneAsync(
value => value.Id == id && value.Status == DonationStatus.Pending,
Builders<Donation>.Update.Set(value => value.Status, DonationStatus.Failed)
.Set(value => value.FailureCode, failureCode).Set(value => value.UpdatedUtc, DateTime.UtcNow));
public async Task<bool> ApplyStripeStatusAsync(
string id,
string sessionId,
long amountCents,
string currency,
string status,
string paymentIntentId,
string eventId)
{
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return false;
var filter = Builders<Donation>.Filter.Eq(value => value.Id, id)
& Builders<Donation>.Filter.Eq(value => value.AmountCents, amountCents)
& Builders<Donation>.Filter.Eq(value => value.Currency, currency.ToLowerInvariant())
& (Builders<Donation>.Filter.Eq(value => value.StripeCheckoutSessionId, sessionId)
| Builders<Donation>.Filter.Eq(value => value.StripeCheckoutSessionId, null));
if (status != DonationStatus.Paid)
filter &= Builders<Donation>.Filter.Eq(value => value.Status, DonationStatus.Pending);
var now = DateTime.UtcNow;
var update = Builders<Donation>.Update.Set(value => value.Status, status)
.Set(value => value.StripeCheckoutSessionId, sessionId)
.Set(value => value.StripePaymentIntentId, paymentIntentId)
.Set(value => value.LastStripeEventId, eventId)
.Set(value => value.UpdatedUtc, now);
if (status == DonationStatus.Paid) update = update.Set(value => value.PaidUtc, now);
return (await _donations.UpdateOneAsync(filter, update)).ModifiedCount > 0;
}
}
@@ -0,0 +1,57 @@
using Stripe;
using Stripe.Checkout;
namespace QuestionsApi.Services;
public sealed record StripeCheckoutResult(string SessionId, string CheckoutUrl);
public interface IStripeDonationGateway
{
Task<StripeCheckoutResult> CreateCheckoutAsync(string donationId, long amountCents, CancellationToken cancellationToken);
}
public sealed class StripeDonationGateway(IConfiguration configuration) : IStripeDonationGateway
{
public async Task<StripeCheckoutResult> CreateCheckoutAsync(string donationId, long amountCents, CancellationToken cancellationToken)
{
var secretKey = configuration["Stripe:SecretKey"];
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
var successUrl = configuration["Stripe:SuccessUrl"]
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
var cancelUrl = configuration["Stripe:CancelUrl"] ?? "https://doctor.ranaze.com/donation-canceled.html";
var client = new StripeClient(secretKey);
var service = new SessionService(client);
var metadata = new Dictionary<string, string> { ["donation_id"] = donationId };
var session = await service.CreateAsync(new SessionCreateOptions
{
Mode = "payment",
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
ClientReferenceId = donationId,
CustomerCreation = "always",
BillingAddressCollection = "auto",
Metadata = metadata,
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
LineItems =
[
new SessionLineItemOptions
{
Quantity = 1,
PriceData = new SessionLineItemPriceDataOptions
{
Currency = "usd",
UnitAmount = amountCents,
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = "Support MedMind",
Description = "One-time contribution supporting MedMind development and medical education content."
}
}
}
]
}, null, cancellationToken);
if (string.IsNullOrWhiteSpace(session.Id) || string.IsNullOrWhiteSpace(session.Url))
throw new InvalidOperationException("Stripe did not return a checkout URL.");
return new StripeCheckoutResult(session.Id, session.Url);
}
}
@@ -2,6 +2,10 @@
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"AnswerStatistics": { "ReconcileSeconds": 300 },
"Stripe": {
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
},
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Logging": { "LogLevel": { "Default": "Information" } }
}
+4
View File
@@ -2,6 +2,10 @@
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"AnswerStatistics": { "ReconcileSeconds": 300 },
"Stripe": {
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
},
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
+10
View File
@@ -35,6 +35,16 @@ spec:
secretKeyRef:
name: medmind-internal-api
key: purge-secret
- name: Stripe__SecretKey
valueFrom:
secretKeyRef:
name: medmind-stripe
key: secret-key
- name: Stripe__WebhookSecret
valueFrom:
secretKeyRef:
name: medmind-stripe
key: webhook-secret
resources:
requests:
cpu: 250m
+56
View File
@@ -0,0 +1,56 @@
# Stripe Donations
MedMind accepts authenticated, one-time USD contributions through Stripe-hosted Checkout. The API enforces a minimum contribution of $5 and a maximum of $10,000. Payment card data never passes through or is stored by MedMind.
Contributions are not tax-deductible.
## Stripe test-mode setup
1. In the Stripe Dashboard, switch to test mode.
2. Create a webhook endpoint at:
`https://s3q.ranaze.com/api/donations/stripe-webhook`
3. Subscribe the endpoint to these events:
- `checkout.session.completed`
- `checkout.session.async_payment_succeeded`
- `checkout.session.async_payment_failed`
- `checkout.session.expired`
4. Copy the webhook signing secret (`whsec_...`).
5. Copy a test secret API key (`sk_test_...`).
6. Add both values as Gitea Actions secrets in the API repository:
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
The deployment workflow writes these values to the Kubernetes secret `medmind-stripe`. Never commit either value to the repository.
## Stripe account settings
Configure MedMind branding in Stripe Checkout and enable successful-payment email receipts in Stripe customer email settings. Stripe Checkout collects the contributor's email address.
## Verification
After deployment:
1. Sign in to MedMind and open **Support MedMind** from the sidebar.
2. Confirm that amounts below $5 and above $10,000 are rejected.
3. Start a $5 test contribution.
4. Complete Checkout using Stripe's standard test card `4242 4242 4242 4242`, any future expiration date, and any CVC.
5. Return to MedMind and confirm the contribution changes from pending to confirmed.
6. In Stripe, confirm the Checkout Session and webhook delivery both succeeded.
7. In MongoDB, confirm the corresponding `Donations` document has `Status: "paid"` and a populated `PaidUtc` value.
The client-visible status endpoint returns only the donation ID, amount, currency, status, creation time, and paid time. Stripe payment identifiers and webhook metadata remain server-side.
## Live-mode cutover
1. Create the same webhook endpoint in Stripe live mode.
2. Replace `STRIPE_SECRET_KEY` with the live secret key (`sk_live_...`).
3. Replace `STRIPE_WEBHOOK_SECRET` with the live endpoint's signing secret.
4. Trigger a deployment and complete a small real contribution.
5. Confirm the payment, receipt, webhook delivery, and `paid` status before announcing availability.
Test and live webhook signing secrets are different. Both Stripe secrets must always belong to the same Stripe mode.
+2
View File
@@ -33,6 +33,7 @@ BookmarkService="*res://scripts/autoload/bookmark_service.gd"
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
DashboardService="*res://scripts/autoload/dashboard_service.gd"
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
DonationService="*res://scripts/autoload/donation_service.gd"
[display]
@@ -60,5 +61,6 @@ app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
notes_api_url="https://s3q.ranaze.com/api/notes"
dashboard_api_url="https://s3q.ranaze.com/api/dashboard"
profile_api_url="https://s3q.ranaze.com/api/profile"
donations_api_url="https://s3q.ranaze.com/api/donations"
review_queue_api_url="https://s3q.ranaze.com/api/review-queue"
build_revision="development"
+176
View File
@@ -0,0 +1,176 @@
extends Node
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_URL := "https://s3q.ranaze.com/api/donations"
const REQUEST_TIMEOUT_SECONDS := 30.0
const POLL_INTERVAL_SECONDS := 3.0
const POLL_LIMIT := 200
signal checkout_started(donation_id: String, checkout_url: String)
signal donation_status_changed(status: String, donation: Dictionary)
signal donation_failed(message: String)
var api_url := DEFAULT_URL
var checkout_request: HTTPRequest
var status_request: HTTPRequest
var poll_timer: Timer
var request_token := ""
var active_donation_id := ""
var poll_count := 0
var checkout_pending := false
var status_pending := false
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/donations_api_url", DEFAULT_URL)),
"/api/donations")
checkout_request = HTTPRequest.new()
checkout_request.timeout = REQUEST_TIMEOUT_SECONDS
checkout_request.request_completed.connect(_on_checkout_completed)
add_child(checkout_request)
status_request = HTTPRequest.new()
status_request.timeout = REQUEST_TIMEOUT_SECONDS
status_request.request_completed.connect(_on_status_completed)
add_child(status_request)
poll_timer = Timer.new()
poll_timer.wait_time = POLL_INTERVAL_SECONDS
poll_timer.timeout.connect(_poll_status)
add_child(poll_timer)
AuthState.auth_changed.connect(_on_auth_changed)
func begin_checkout(amount_cents: int) -> void:
if checkout_pending:
return
if not AuthState.can_send_authenticated_request():
donation_failed.emit("Log in before making a contribution.")
return
_prepare_browser_window()
checkout_pending = true
request_token = AuthState.access_token
var headers := PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % request_token
])
var error := checkout_request.request(
"%s/checkout" % api_url.rstrip("/"), headers, HTTPClient.METHOD_POST,
JSON.stringify({"amountCents": amount_cents}))
if error != OK:
checkout_pending = false
_close_browser_window()
donation_failed.emit("The secure checkout request could not be started.")
func stop_polling() -> void:
poll_timer.stop()
poll_count = 0
if status_pending and status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
status_request.cancel_request()
status_pending = false
func resume_polling() -> void:
if active_donation_id.is_empty() or poll_timer == null:
return
poll_timer.start()
_poll_status()
func _on_checkout_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
checkout_pending = false
if response_code == 401:
AuthState.handle_unauthorized(request_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
_close_browser_window()
donation_failed.emit(_failure_message("Checkout could not be started.", result, response_code, body))
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
_close_browser_window()
donation_failed.emit("The donation service returned an invalid response.")
return
var donation_id := str(parsed.get("donationId", ""))
var checkout_url := str(parsed.get("checkoutUrl", ""))
if donation_id.is_empty() or not checkout_url.begins_with("https://"):
_close_browser_window()
donation_failed.emit("The donation service returned an invalid checkout link.")
return
active_donation_id = donation_id
_open_checkout_url(checkout_url)
checkout_started.emit(donation_id, checkout_url)
poll_count = 0
poll_timer.start()
_poll_status()
func _poll_status() -> void:
if status_pending or active_donation_id.is_empty() or not AuthState.can_send_authenticated_request():
return
poll_count += 1
if poll_count > POLL_LIMIT:
stop_polling()
return
status_pending = true
request_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % request_token])
var error := status_request.request("%s/%s" % [api_url.rstrip("/"), active_donation_id], headers)
if error != OK:
status_pending = false
func _on_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
status_pending = false
if response_code == 401:
AuthState.handle_unauthorized(request_token)
stop_polling()
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
return
var status := str(parsed.get("status", ""))
donation_status_changed.emit(status, parsed)
if status in ["paid", "failed", "expired"]:
stop_polling()
func _prepare_browser_window() -> void:
if OS.has_feature("web"):
JavaScriptBridge.eval(
"window.medmindDonationWindow = window.open('about:blank', 'medmind_donation_checkout');",
true)
func _open_checkout_url(url: String) -> void:
if OS.has_feature("web"):
JavaScriptBridge.eval(
"if (window.medmindDonationWindow && !window.medmindDonationWindow.closed) { window.medmindDonationWindow.location.href = %s; window.medmindDonationWindow.focus(); } else { window.open(%s, '_blank'); }" % [JSON.stringify(url), JSON.stringify(url)],
true)
else:
OS.shell_open(url)
func _close_browser_window() -> void:
if OS.has_feature("web"):
JavaScriptBridge.eval(
"if (window.medmindDonationWindow && !window.medmindDonationWindow.closed) { window.medmindDonationWindow.close(); } window.medmindDonationWindow = null;",
true)
func _failure_message(prefix: String, result: int, response_code: int, body: PackedByteArray) -> String:
if result != HTTPRequest.RESULT_SUCCESS and response_code == 0:
return "%s Check your connection and try again." % prefix
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) == TYPE_DICTIONARY:
var detail := str(parsed.get("detail", "")).strip_edges()
if not detail.is_empty():
return detail
return "%s Status %d." % [prefix, response_code]
func _on_auth_changed() -> void:
if not AuthState.is_logged_in:
stop_polling()
active_donation_id = ""
+1
View File
@@ -0,0 +1 @@
uid://b20e6ch7a4dtb
+18
View File
@@ -0,0 +1,18 @@
extends RefCounted
static func parse_cents(value: String) -> int:
var cleaned := value.strip_edges().trim_prefix("$").replace(",", "")
var matcher := RegEx.new()
matcher.compile("^[0-9]+(?:\\.[0-9]{1,2})?$")
if matcher.search(cleaned) == null:
return -1
var parts := cleaned.split(".")
var dollars := int(parts[0])
var cents := 0
if parts.size() == 2:
var fraction := str(parts[1])
cents = int(fraction) * (10 if fraction.length() == 1 else 1)
if dollars > 10000 or (dollars == 10000 and cents > 0):
return -1
return dollars * 100 + cents
+1
View File
@@ -0,0 +1 @@
uid://2ha4l3mmlb61
+316
View File
@@ -0,0 +1,316 @@
extends MarginContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const Style := preload("res://scripts/ui/ui_style.gd")
const DonationAmount := preload("res://scripts/ui/donation_amount.gd")
const MINIMUM_CENTS := 500
const MAXIMUM_CENTS := 1000000
const DEFAULT_CENTS := 2500
var main: Control
var content_grid: GridContainer
var mission_panel: PanelContainer
var contribution_panel: PanelContainer
var preset_grid: GridContainer
var amount_buttons: Array[Button] = []
var amount_group := ButtonGroup.new()
var custom_amount: LineEdit
var donate_button: Button
var status_label: Label
var selected_amount_cents := DEFAULT_CENTS
var donation_service: Node
func _ready() -> void:
donation_service = get_node("/root/DonationService")
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("margin_bottom", 8)
_build_ui()
resized.connect(_update_layout)
donation_service.checkout_started.connect(_on_checkout_started)
donation_service.donation_status_changed.connect(_on_donation_status_changed)
donation_service.donation_failed.connect(_on_donation_failed)
UIThemeService.theme_changed.connect(_on_theme_changed)
_update_layout()
_update_donate_button()
refresh_theme_controls()
func _build_ui() -> void:
var scroll := ScrollContainer.new()
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
var page := VBoxContainer.new()
page.custom_minimum_size = Vector2(0, 0)
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
page.add_theme_constant_override("separation", 24)
scroll.add_child(page)
var title := Label.new()
title.text = "Support MedMind"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 36)
page.add_child(title)
var subtitle := Label.new()
subtitle.text = "Help us build accessible, clinically grounded tools for Step 3 learners."
subtitle.add_theme_font_override("font", Brand.font)
subtitle.add_theme_font_size_override("font_size", 18)
subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
page.add_child(subtitle)
content_grid = GridContainer.new()
content_grid.columns = 2
content_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_grid.add_theme_constant_override("h_separation", 24)
content_grid.add_theme_constant_override("v_separation", 24)
page.add_child(content_grid)
mission_panel = _panel()
mission_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_grid.add_child(mission_panel)
var mission := VBoxContainer.new()
mission.add_theme_constant_override("separation", 18)
mission_panel.add_child(mission)
mission.add_child(_heading("Our mission", 26))
mission.add_child(_body("MedMind exists to make rigorous, clinically grounded medical education more accessible. We are building an independent Step 3 study experience that helps learners develop clinical judgment through original questions, realistic CCS cases, transparent explanations, and continuous improvement driven by learner feedback."))
mission.add_child(HSeparator.new())
mission.add_child(_heading("What your support makes possible", 20))
for item_text in [
"Original question and CCS case development",
"Clinical source review and content verification",
"Reliable hosting and continued pre-alpha access",
"Accessibility, usability, and learning-tool improvements"
]:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
var marker := Label.new()
marker.text = "-"
marker.add_theme_font_size_override("font_size", 20)
marker.add_theme_color_override("font_color", Brand.TEAL)
row.add_child(marker)
var item := _body(item_text)
item.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(item)
mission.add_child(row)
contribution_panel = _panel()
contribution_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content_grid.add_child(contribution_panel)
var contribution := VBoxContainer.new()
contribution.add_theme_constant_override("separation", 18)
contribution_panel.add_child(contribution)
contribution.add_child(_heading("Make a one-time contribution", 26))
contribution.add_child(_body("Choose an amount. Secure payment and email receipts are handled by Stripe."))
preset_grid = GridContainer.new()
preset_grid.columns = 4
preset_grid.add_theme_constant_override("h_separation", 10)
preset_grid.add_theme_constant_override("v_separation", 10)
contribution.add_child(preset_grid)
for amount in [500, 1000, 2500, 5000]:
var button := Button.new()
button.text = "$%d" % int(amount / 100.0)
button.toggle_mode = true
button.button_group = amount_group
button.custom_minimum_size = Vector2(64, 48)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.set_meta("amount_cents", amount)
button.pressed.connect(_select_preset.bind(button))
preset_grid.add_child(button)
amount_buttons.append(button)
if amount == DEFAULT_CENTS:
button.set_pressed_no_signal(true)
var custom_label := Label.new()
custom_label.text = "Custom amount (USD)"
custom_label.add_theme_font_override("font", Brand.font)
custom_label.add_theme_font_size_override("font_size", 16)
contribution.add_child(custom_label)
custom_amount = LineEdit.new()
custom_amount.placeholder_text = "$5.00 minimum"
custom_amount.max_length = 12
custom_amount.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER_DECIMAL
custom_amount.custom_minimum_size = Vector2(0, 56)
custom_amount.text_changed.connect(_on_custom_amount_changed)
contribution.add_child(custom_amount)
donate_button = Button.new()
donate_button.text = "Donate $25 securely"
donate_button.custom_minimum_size = Vector2(0, 58)
donate_button.pressed.connect(_on_donate_pressed)
contribution.add_child(donate_button)
status_label = Label.new()
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
status_label.add_theme_font_override("font", Brand.font)
status_label.add_theme_font_size_override("font_size", 15)
status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
status_label.custom_minimum_size.y = 44
contribution.add_child(status_label)
var disclosure := _body("Contributions are voluntary, one-time, and not tax-deductible. Card details are entered only on Stripe's secure checkout page.")
disclosure.add_theme_font_size_override("font_size", 14)
contribution.add_child(disclosure)
func on_show(_payload: Variant = null) -> void:
donate_button.disabled = donation_service.checkout_pending
if donation_service.active_donation_id.is_empty():
status_label.text = ""
else:
donation_service.resume_polling()
func on_hide() -> void:
donation_service.stop_polling()
static func parse_amount_cents(value: String) -> int:
return DonationAmount.parse_cents(value)
func _select_preset(button: Button) -> void:
selected_amount_cents = int(button.get_meta("amount_cents", DEFAULT_CENTS))
custom_amount.text = ""
_set_validation_state("")
_update_donate_button()
refresh_theme_controls()
func _on_custom_amount_changed(value: String) -> void:
if value.strip_edges().is_empty():
return
for button in amount_buttons:
button.set_pressed_no_signal(false)
selected_amount_cents = parse_amount_cents(value)
_set_validation_state("")
_update_donate_button()
refresh_theme_controls()
func _on_donate_pressed() -> void:
if selected_amount_cents < MINIMUM_CENTS:
_set_validation_state("Enter an amount of at least $5.00.", true)
return
if selected_amount_cents > MAXIMUM_CENTS:
_set_validation_state("The maximum contribution is $10,000.00.", true)
return
donate_button.disabled = true
_set_validation_state("Opening secure checkout...")
donation_service.begin_checkout(selected_amount_cents)
func _on_checkout_started(_donation_id: String, _checkout_url: String) -> void:
donate_button.disabled = false
_set_validation_state("Checkout opened in your browser. This page will confirm when Stripe completes the payment.")
func _on_donation_status_changed(status: String, _donation: Dictionary) -> void:
match status:
"paid": _set_validation_state("Thank you for supporting MedMind. Your contribution has been confirmed.", false, Brand.SUCCESS)
"failed": _set_validation_state("Stripe could not complete the contribution. No payment was confirmed.", true)
"expired": _set_validation_state("The checkout session expired. Start a new checkout when you are ready.", true)
func _on_donation_failed(message: String) -> void:
donate_button.disabled = false
_set_validation_state(message, true)
func _update_donate_button() -> void:
donate_button.disabled = selected_amount_cents < MINIMUM_CENTS or selected_amount_cents > MAXIMUM_CENTS
if selected_amount_cents >= MINIMUM_CENTS and selected_amount_cents <= MAXIMUM_CENTS:
donate_button.text = "Donate %s securely" % _format_amount(selected_amount_cents)
else:
donate_button.text = "Donate securely"
func _format_amount(amount_cents: int) -> String:
return "$%d.%02d" % [int(amount_cents / 100.0), amount_cents % 100]
func _set_validation_state(message: String, is_error: bool = false, color: Color = Color.TRANSPARENT) -> void:
status_label.text = message
var text_color := color
if text_color == Color.TRANSPARENT:
text_color = Style.RED if is_error else Brand.muted_color(UIThemeService.is_dark_mode())
status_label.add_theme_color_override("font_color", text_color)
func _panel() -> PanelContainer:
var panel := PanelContainer.new()
var box := Brand.content_card_box(UIThemeService.is_dark_mode(), false)
box.content_margin_left = 28
box.content_margin_right = 28
box.content_margin_top = 28
box.content_margin_bottom = 28
panel.add_theme_stylebox_override("panel", box)
return panel
func _heading(value: String, font_size: int) -> Label:
var label := Label.new()
label.text = value
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", font_size)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
return label
func _body(value: String) -> Label:
var label := Label.new()
label.text = value
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", 17)
label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
return label
func _update_layout() -> void:
if content_grid != null:
content_grid.columns = 1 if size.x < 1050.0 else 2
if preset_grid != null:
preset_grid.columns = 2 if size.x < 520.0 else 4
func refresh_theme_controls() -> void:
var dark := UIThemeService.is_dark_mode()
for panel in [mission_panel, contribution_panel]:
if panel != null:
var box := Brand.content_card_box(dark, false)
box.content_margin_left = 28
box.content_margin_right = 28
box.content_margin_top = 28
box.content_margin_bottom = 28
panel.add_theme_stylebox_override("panel", box)
if custom_amount != null:
custom_amount.add_theme_stylebox_override("normal", Brand.field_box(dark))
custom_amount.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
custom_amount.add_theme_color_override("font_color", Brand.text_color(dark))
custom_amount.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
for button in amount_buttons:
var selected := button.button_pressed
var normal := Brand.secondary_button_box(dark, selected)
if selected:
normal.bg_color = Color(Brand.ROYAL_BLUE, 0.22 if dark else 0.10)
normal.border_color = Brand.ROYAL_BLUE
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("pressed", normal)
button.add_theme_color_override("font_color", Brand.text_color(dark))
button.add_theme_color_override("font_pressed_color", Brand.text_color(dark))
if donate_button != null:
donate_button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
donate_button.add_theme_stylebox_override("hover", Brand.gradient_button_box())
donate_button.add_theme_color_override("font_color", Color.WHITE)
donate_button.add_theme_color_override("font_hover_color", Color.WHITE)
func _on_theme_changed(_is_dark_mode: bool) -> void:
refresh_theme_controls()
+1
View File
@@ -0,0 +1 @@
uid://i2fix2vkf8au
+12 -1
View File
@@ -314,6 +314,11 @@ func _build_ui() -> void:
bookmarks.main = self
screens_container.add_child(bookmarks)
screens["bookmarks"] = bookmarks
var donations := preload("res://scripts/ui/donation_screen.gd").new()
donations.name = "DonationScreen"
donations.main = self
screens_container.add_child(donations)
screens["donations"] = donations
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
feedback_dialog.name = "AppFeedbackDialog"
@@ -386,6 +391,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
container.add_child(spacer)
container.add_child(HSeparator.new())
_add_nav_button(container, "donations", "Support MedMind", "heart-pulse", true)
_add_nav_button(container, "performance", "Profile", "user-round", true)
_add_nav_button(container, "settings", "Settings", "settings", true)
_add_nav_button(container, "logout", "Log out", "log-out", true)
@@ -426,6 +432,7 @@ func _on_navigation_pressed(key: String) -> void:
"settings": show_screen("settings")
"notes": show_screen("notes")
"bookmarks": show_screen("bookmarks")
"donations": show_screen("donations")
"logout": _logout()
@@ -440,7 +447,8 @@ func _update_header_title(screen_name: String) -> void:
"profile": "Profile",
"settings": "Settings",
"notes": "Notes",
"bookmarks": "Bookmarks"
"bookmarks": "Bookmarks",
"donations": "Support MedMind"
}
header_title.text = str(titles.get(screen_name, ""))
@@ -735,6 +743,9 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
var selector = screens.get("selection")
if selector != null and selector.has_method("refresh_theme_controls"):
selector.refresh_theme_controls()
var donations = screens.get("donations")
if donations != null and donations.has_method("refresh_theme_controls"):
donations.refresh_theme_controls()
var player = screens.get("player")
if player != null and player.has_method("refresh_theme_controls"):
player.refresh_theme_controls()
+2
View File
@@ -23,4 +23,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
key = "notes"
elif screen_name == "bookmarks":
key = "bookmarks"
elif screen_name == "donations":
key = "donations"
return key
+21
View File
@@ -0,0 +1,21 @@
extends SceneTree
const DonationAmount := preload("res://scripts/ui/donation_amount.gd")
var failures := 0
func _initialize() -> void:
_assert(DonationAmount.parse_cents("5") == 500, "whole dollars should parse")
_assert(DonationAmount.parse_cents("$25.50") == 2550, "dollars and cents should parse")
_assert(DonationAmount.parse_cents("4.999") == -1, "more than two decimal places should fail")
_assert(DonationAmount.parse_cents("10000.01") == -1, "amounts above the cap should fail")
_assert(DonationAmount.parse_cents("abc") == -1, "non-numeric values should fail")
quit(1 if failures > 0 else 0)
func _assert(condition: bool, message: String) -> void:
if condition:
return
failures += 1
push_error(message)
+1
View File
@@ -0,0 +1 @@
uid://d0ik8fhaa2u8w
+47
View File
@@ -0,0 +1,47 @@
extends SceneTree
var failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var theme_service = root.get_node("UIThemeService")
var prior_dark_mode: bool = theme_service.is_dark_mode()
var host := Control.new()
host.size = Vector2(1280, 800)
root.add_child(host)
var screen = load("res://scripts/ui/donation_screen.gd").new()
screen.size = host.size
host.add_child(screen)
await process_frame
_assert(screen.content_grid.columns == 2, "wide donation layouts should use two columns")
_assert(screen.selected_amount_cents == 2500, "the default preset should be $25")
_assert(screen.donate_button.text.contains("$25.00"), "the donation action should state the selected amount")
screen.size = Vector2(760, 800)
screen._update_layout()
_assert(screen.content_grid.columns == 1, "narrow donation layouts should stack")
screen.size = Vector2(360, 800)
screen._update_layout()
_assert(screen.preset_grid.columns == 2, "phone donation layouts should use a two-column amount grid")
screen.custom_amount.text = "4"
screen._on_custom_amount_changed("4")
await process_frame
_assert(screen.donate_button.disabled, "amounts below $5 should disable checkout")
theme_service.set_dark_mode(false)
await process_frame
var light_box := screen.mission_panel.get_theme_stylebox("panel") as StyleBoxFlat
_assert(light_box.bg_color.is_equal_approx(Color.WHITE), "donation cards should use light surfaces in light mode")
host.queue_free()
await process_frame
theme_service.set_dark_mode(prior_dark_mode)
quit(1 if failures > 0 else 0)
func _assert(condition: bool, message: String) -> void:
if condition:
return
failures += 1
push_error(message)
+1
View File
@@ -0,0 +1 @@
uid://cfg0oxxrtumdj
+1
View File
@@ -19,6 +19,7 @@ func _initialize() -> void:
_assert(NavigationRoutes.key_for_screen("settings") == "settings", "settings should activate Settings")
_assert(NavigationRoutes.key_for_screen("notes") == "notes", "notes should activate Notes")
_assert(NavigationRoutes.key_for_screen("bookmarks") == "bookmarks", "bookmarks should activate Bookmarks")
_assert(NavigationRoutes.key_for_screen("donations") == "donations", "donations should activate Support MedMind")
quit(1 if failures > 0 else 0)
+2
View File
@@ -74,6 +74,8 @@ func _run() -> void:
_assert(main_screen.sidebar.find_child("NavPractice", true, false) == null, "the sidebar should not retain a Practice navigation item")
var profile_nav := main_screen.sidebar.find_child("NavPerformance", true, false) as Button
var settings_nav := main_screen.sidebar.find_child("NavSettings", true, false) as Button
var donation_nav := main_screen.sidebar.find_child("NavDonations", true, false) as Button
_assert(donation_nav != null and donation_nav.text == "Support MedMind", "the shell should expose donation navigation")
_assert(profile_nav != null and settings_nav != null and profile_nav.get_index() + 1 == settings_nav.get_index(), "Profile should sit directly above Settings in the lower navigation group")
var theme_service = root.get_node("UIThemeService")
var prior_dark_mode: bool = theme_service.is_dark_mode()
+4
View File
@@ -35,9 +35,13 @@ RUN mkdir -p /build \
&& godot --verbose --headless --path /src --export-release Web /build/index.html \
&& cp /src/assets/branding/medmind-loader-mark.png /build/medmind-loader-icon.png \
&& cp /src/assets/fonts/Inter-Variable.ttf /build/medmind-inter.ttf \
&& cp /src/web/donation-complete.html /build/donation-complete.html \
&& cp /src/web/donation-canceled.html /build/donation-canceled.html \
&& test -s /build/index.html \
&& test -s /build/medmind-loader-icon.png \
&& test -s /build/medmind-inter.ttf \
&& test -s /build/donation-complete.html \
&& test -s /build/donation-canceled.html \
&& test -s /build/index.wasm \
&& test -s /build/index.pck
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Checkout canceled | MedMind</title>
<style>
@font-face{font-family:Inter;src:url('/medmind-inter.ttf') format('truetype');font-display:swap}
:root{color-scheme:dark light;--bg:#071224;--surface:#0b1427;--border:#33415d;--text:#f8fafc;--muted:#a9b5ca;--accent:#38bdf8}
@media(prefers-color-scheme:light){:root{--bg:#f1f5f9;--surface:#fff;--border:#dbe3ef;--text:#0f172a;--muted:#64748b}}
:root[data-theme=light]{--bg:#f1f5f9;--surface:#fff;--border:#dbe3ef;--text:#0f172a;--muted:#64748b}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif}
main{width:min(560px,100%);padding:40px;border:1px solid var(--border);border-radius:12px;background:var(--surface);text-align:center}
img{width:84px;height:84px;object-fit:contain}h1{margin:22px 0 12px;font-size:30px}p{margin:0;color:var(--muted);font-size:17px;line-height:1.6}
</style>
</head>
<script>try{document.documentElement.dataset.theme=localStorage.getItem('medmind-color-scheme')||''}catch(e){}</script>
<body><main><img src="/medmind-loader-icon.png" alt="MedMind"><h1>Checkout canceled</h1><p>No contribution was completed. You can close this tab and return to MedMind.</p></main></body>
</html>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Contribution received | MedMind</title>
<style>
@font-face{font-family:Inter;src:url('/medmind-inter.ttf') format('truetype');font-display:swap}
:root{color-scheme:dark light;--bg:#071224;--surface:#0b1427;--border:#33415d;--text:#f8fafc;--muted:#a9b5ca;--accent:#38bdf8;--success:#22c55e}
@media(prefers-color-scheme:light){:root{--bg:#f1f5f9;--surface:#fff;--border:#dbe3ef;--text:#0f172a;--muted:#64748b}}
:root[data-theme=light]{--bg:#f1f5f9;--surface:#fff;--border:#dbe3ef;--text:#0f172a;--muted:#64748b}
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif}
main{width:min(560px,100%);padding:40px;border:1px solid var(--border);border-radius:12px;background:var(--surface);text-align:center}
img{width:84px;height:84px;object-fit:contain}h1{margin:22px 0 12px;font-size:30px}p{margin:0;color:var(--muted);font-size:17px;line-height:1.6}.status{color:var(--success);font-weight:700}
</style>
</head>
<script>try{document.documentElement.dataset.theme=localStorage.getItem('medmind-color-scheme')||''}catch(e){}</script>
<body><main><img src="/medmind-loader-icon.png" alt="MedMind"><h1>Thank you for supporting MedMind</h1><p class="status">Your checkout is complete.</p><p>You can close this tab and return to MedMind. Stripe will send your receipt by email.</p></main></body>
</html>