Add Stripe donation flow
This commit is contained in:
@@ -79,6 +79,18 @@ jobs:
|
|||||||
--from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \
|
--from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \
|
||||||
--dry-run=client -o yaml | kubectl apply -n step3-questions -f -
|
--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
|
- name: Apply Questions deployment & service
|
||||||
run: |
|
run: |
|
||||||
kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions
|
kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions
|
||||||
|
|||||||
@@ -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 ?? "";
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
|
|||||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||||
builder.Services.AddSingleton<DashboardStore>();
|
builder.Services.AddSingleton<DashboardStore>();
|
||||||
builder.Services.AddSingleton<UserProfileStore>();
|
builder.Services.AddSingleton<UserProfileStore>();
|
||||||
|
builder.Services.AddSingleton<DonationStore>();
|
||||||
|
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
|
||||||
builder.Services.AddSingleton<ServiceMetrics>();
|
builder.Services.AddSingleton<ServiceMetrics>();
|
||||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
|
||||||
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
|
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
|
||||||
|
<PackageReference Include="Stripe.net" Version="52.1.1" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
</ItemGroup>
|
</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" } } },
|
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } },
|
||||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
"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" },
|
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||||
"Logging": { "LogLevel": { "Default": "Information" } }
|
"Logging": { "LogLevel": { "Default": "Information" } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } },
|
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5008" } } },
|
||||||
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
|
||||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
"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" },
|
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||||
"Logging": { "LogLevel": { "Default": "Information" } },
|
"Logging": { "LogLevel": { "Default": "Information" } },
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: medmind-internal-api
|
name: medmind-internal-api
|
||||||
key: purge-secret
|
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:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 250m
|
cpu: 250m
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -33,6 +33,7 @@ BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
|||||||
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
||||||
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||||
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
||||||
|
DonationService="*res://scripts/autoload/donation_service.gd"
|
||||||
|
|
||||||
[display]
|
[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"
|
notes_api_url="https://s3q.ranaze.com/api/notes"
|
||||||
dashboard_api_url="https://s3q.ranaze.com/api/dashboard"
|
dashboard_api_url="https://s3q.ranaze.com/api/dashboard"
|
||||||
profile_api_url="https://s3q.ranaze.com/api/profile"
|
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"
|
review_queue_api_url="https://s3q.ranaze.com/api/review-queue"
|
||||||
build_revision="development"
|
build_revision="development"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b20e6ch7a4dtb
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://2ha4l3mmlb61
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://i2fix2vkf8au
|
||||||
+12
-1
@@ -314,6 +314,11 @@ func _build_ui() -> void:
|
|||||||
bookmarks.main = self
|
bookmarks.main = self
|
||||||
screens_container.add_child(bookmarks)
|
screens_container.add_child(bookmarks)
|
||||||
screens["bookmarks"] = 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 = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||||
feedback_dialog.name = "AppFeedbackDialog"
|
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
|
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
container.add_child(spacer)
|
container.add_child(spacer)
|
||||||
container.add_child(HSeparator.new())
|
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, "performance", "Profile", "user-round", true)
|
||||||
_add_nav_button(container, "settings", "Settings", "settings", true)
|
_add_nav_button(container, "settings", "Settings", "settings", true)
|
||||||
_add_nav_button(container, "logout", "Log out", "log-out", 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")
|
"settings": show_screen("settings")
|
||||||
"notes": show_screen("notes")
|
"notes": show_screen("notes")
|
||||||
"bookmarks": show_screen("bookmarks")
|
"bookmarks": show_screen("bookmarks")
|
||||||
|
"donations": show_screen("donations")
|
||||||
"logout": _logout()
|
"logout": _logout()
|
||||||
|
|
||||||
|
|
||||||
@@ -440,7 +447,8 @@ func _update_header_title(screen_name: String) -> void:
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"bookmarks": "Bookmarks"
|
"bookmarks": "Bookmarks",
|
||||||
|
"donations": "Support MedMind"
|
||||||
}
|
}
|
||||||
header_title.text = str(titles.get(screen_name, ""))
|
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")
|
var selector = screens.get("selection")
|
||||||
if selector != null and selector.has_method("refresh_theme_controls"):
|
if selector != null and selector.has_method("refresh_theme_controls"):
|
||||||
selector.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")
|
var player = screens.get("player")
|
||||||
if player != null and player.has_method("refresh_theme_controls"):
|
if player != null and player.has_method("refresh_theme_controls"):
|
||||||
player.refresh_theme_controls()
|
player.refresh_theme_controls()
|
||||||
|
|||||||
@@ -23,4 +23,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
|||||||
key = "notes"
|
key = "notes"
|
||||||
elif screen_name == "bookmarks":
|
elif screen_name == "bookmarks":
|
||||||
key = "bookmarks"
|
key = "bookmarks"
|
||||||
|
elif screen_name == "donations":
|
||||||
|
key = "donations"
|
||||||
return key
|
return key
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d0ik8fhaa2u8w
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cfg0oxxrtumdj
|
||||||
@@ -19,6 +19,7 @@ func _initialize() -> void:
|
|||||||
_assert(NavigationRoutes.key_for_screen("settings") == "settings", "settings should activate Settings")
|
_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("notes") == "notes", "notes should activate Notes")
|
||||||
_assert(NavigationRoutes.key_for_screen("bookmarks") == "bookmarks", "bookmarks should activate Bookmarks")
|
_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)
|
quit(1 if failures > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
_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 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 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")
|
_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 theme_service = root.get_node("UIThemeService")
|
||||||
var prior_dark_mode: bool = theme_service.is_dark_mode()
|
var prior_dark_mode: bool = theme_service.is_dark_mode()
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ RUN mkdir -p /build \
|
|||||||
&& godot --verbose --headless --path /src --export-release Web /build/index.html \
|
&& 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/branding/medmind-loader-mark.png /build/medmind-loader-icon.png \
|
||||||
&& cp /src/assets/fonts/Inter-Variable.ttf /build/medmind-inter.ttf \
|
&& 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/index.html \
|
||||||
&& test -s /build/medmind-loader-icon.png \
|
&& test -s /build/medmind-loader-icon.png \
|
||||||
&& test -s /build/medmind-inter.ttf \
|
&& 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.wasm \
|
||||||
&& test -s /build/index.pck
|
&& test -s /build/index.pck
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user