340 lines
12 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/** @jsxRuntime automatic */
/** @jsxImportSource hono/jsx */
import {SwishIcon} from '@fluxer/marketing/src/components/icons/SwishIcon';
import type {MarketingContext} from '@fluxer/marketing/src/MarketingContext';
import {escapeInlineScriptValue} from '@fluxer/marketing/src/pages/InlineScriptEscaping';
export interface DonationI18n {
email: string;
emailPlaceholder: string;
amount: string;
amountOther: string;
amountPlaceholder: string;
donationType: string;
oneTime: string;
monthly: string;
yearly: string;
orLabel: string;
currency: string;
donate: string;
processing: string;
errorInvalidEmail: string;
errorInvalidAmount: string;
errorGeneric: string;
errorNetwork: string;
}
export function getDonationI18n(ctx: MarketingContext): DonationI18n {
return {
email: ctx.i18n.getMessage('donations.form.email', ctx.locale),
emailPlaceholder: ctx.i18n.getMessage('donations.form.email_placeholder', ctx.locale),
amount: ctx.i18n.getMessage('donations.form.amount', ctx.locale),
amountOther: ctx.i18n.getMessage('donations.form.amount_other', ctx.locale),
amountPlaceholder: ctx.i18n.getMessage('donations.form.amount_placeholder', ctx.locale),
donationType: ctx.i18n.getMessage('donations.form.donation_type', ctx.locale),
oneTime: ctx.i18n.getMessage('donations.form.one_time', ctx.locale),
monthly: ctx.i18n.getMessage('donations.form.monthly', ctx.locale),
yearly: ctx.i18n.getMessage('donations.form.yearly', ctx.locale),
orLabel: ctx.i18n.getMessage('donations.form.or_label', ctx.locale),
currency: ctx.i18n.getMessage('donations.form.currency', ctx.locale),
donate: ctx.i18n.getMessage('donations.donate.action', ctx.locale),
processing: ctx.i18n.getMessage('donations.form.processing', ctx.locale),
errorInvalidEmail: ctx.i18n.getMessage('donations.errors.invalid_email', ctx.locale),
errorInvalidAmount: ctx.i18n.getMessage('donations.errors.invalid_amount', ctx.locale),
errorGeneric: ctx.i18n.getMessage('donations.errors.generic', ctx.locale),
errorNetwork: ctx.i18n.getMessage('donations.errors.network', ctx.locale),
};
}
export function renderDonationForm(
ctx: MarketingContext,
type: 'individual' | 'business',
isHidden: boolean,
): JSX.Element {
const i18n = getDonationI18n(ctx);
return (
<div id={`donate-content-${type}`} class={isHidden ? 'donate-content hidden w-full' : 'donate-content w-full'}>
<div class="mx-auto max-w-lg space-y-6">
<div>
<label for={`donation-email-${type}`} class="mb-2 block font-medium text-foreground text-sm">
{i18n.email}
</label>
<input
type="email"
id={`donation-email-${type}`}
placeholder={i18n.emailPlaceholder}
class="w-full rounded-lg border border-gray-200 px-4 py-3 text-foreground focus:border-[#4641D9] focus:outline-none"
/>
</div>
<div>
<span class="mb-2 block font-medium text-foreground text-sm">{i18n.amount}</span>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
{[5, 25, 50, 100, 500].map((amount) => (
<button
type="button"
id={`amount-btn-${type}-${amount}`}
onclick={`selectDonationAmount('${type}', ${amount})`}
class={
amount === 25
? 'donation-amount-btn rounded-lg border border-[#4641D9] px-4 py-2 font-medium text-[#4641D9] transition hover:border-[#4641D9] hover:text-[#4641D9]'
: 'donation-amount-btn rounded-lg border border-gray-200 px-4 py-2 font-medium text-gray-700 transition hover:border-[#4641D9] hover:text-[#4641D9]'
}
>
${amount}
</button>
))}
<button
type="button"
id={`amount-btn-${type}-custom`}
onclick={`showCustomDonationAmount('${type}')`}
class="donation-amount-btn rounded-lg border border-gray-200 px-4 py-2 font-medium text-gray-700 transition hover:border-[#4641D9] hover:text-[#4641D9]"
>
{i18n.amountOther}
</button>
</div>
<input
type="number"
id={`custom-amount-${type}`}
min="5"
max="1000"
placeholder={i18n.amountPlaceholder}
class="mt-2 hidden w-full rounded-lg border border-gray-200 px-4 py-3"
/>
</div>
<div>
<span class="mb-2 block font-medium text-foreground text-sm">{i18n.donationType}</span>
<div class="flex gap-2">
<button
type="button"
id={`donation-type-${type}-once`}
onclick={`selectDonationType('${type}', 'once')`}
class="donation-type-btn flex-1 rounded-lg border-2 border-[#4641D9] bg-[#4641D9] px-4 py-2 font-medium text-white"
>
{i18n.oneTime}
</button>
<button
type="button"
id={`donation-type-${type}-month`}
onclick={`selectDonationType('${type}', 'month')`}
class="donation-type-btn flex-1 rounded-lg border-2 border-gray-200 px-4 py-2 font-medium text-gray-700"
>
{i18n.monthly}
</button>
<button
type="button"
id={`donation-type-${type}-year`}
onclick={`selectDonationType('${type}', 'year')`}
class="donation-type-btn flex-1 rounded-lg border-2 border-gray-200 px-4 py-2 font-medium text-gray-700"
>
{i18n.yearly}
</button>
</div>
</div>
<div>
<span class="mb-2 block font-medium text-foreground text-sm">{i18n.currency}</span>
<div class="flex gap-2">
<button
type="button"
id={`currency-${type}-usd`}
onclick={`selectDonationCurrency('${type}', 'usd')`}
class="donation-currency-btn flex-1 rounded-lg border-2 border-[#4641D9] bg-[#4641D9] px-4 py-2 font-medium text-white"
>
$ USD
</button>
<button
type="button"
id={`currency-${type}-eur`}
onclick={`selectDonationCurrency('${type}', 'eur')`}
class="donation-currency-btn flex-1 rounded-lg border-2 border-gray-200 px-4 py-2 font-medium text-gray-700"
>
EUR
</button>
</div>
</div>
<button
type="button"
id={`donate-btn-${type}`}
onclick={`submitDonation('${type}')`}
class="w-full rounded-xl bg-[#4641D9] py-3 font-semibold text-white transition-colors hover:bg-[#3d38c7]"
>
{i18n.donate}
</button>
{type === 'individual' ? (
<div class="flex items-center gap-3 py-1 text-gray-500 text-xs uppercase">
<span class="h-px flex-1 bg-gray-200" />
<span class="font-semibold">{i18n.orLabel}</span>
<span class="h-px flex-1 bg-gray-200" />
</div>
) : null}
{type === 'individual' ? (
<a href={SWISH_URL} class={DONATE_BTN_SECONDARY}>
<SwishIcon size={36} />
Swish
</a>
) : null}
<p id={`donation-error-${type}`} class="hidden text-center text-red-500 text-sm" />
</div>
</div>
);
}
export function renderDonationScript(ctx: MarketingContext): JSX.Element {
const apiEndpoint = ctx.apiEndpoint;
const i18n = getDonationI18n(ctx);
return (
<script
dangerouslySetInnerHTML={{
__html: `var donationState = {
individual: {amount: 25, donationType: 'once', currency: 'usd', customAmount: false},
business: {amount: 25, donationType: 'once', currency: 'usd', customAmount: false}
};
function selectDonationAmount(type, amount) {
donationState[type].amount = amount;
donationState[type].customAmount = false;
var customInput = document.getElementById('custom-amount-' + type);
customInput.classList.add('hidden');
var buttons = document.querySelectorAll('[id^="amount-btn-' + type + '-"]');
buttons.forEach(function(btn) {
btn.classList.remove('border-[#4641D9]', 'text-[#4641D9]');
btn.classList.add('border-gray-200', 'text-gray-700');
});
var selected = document.getElementById('amount-btn-' + type + '-' + amount);
if (selected) {
selected.classList.add('border-[#4641D9]', 'text-[#4641D9]');
selected.classList.remove('border-gray-200', 'text-gray-700');
}
}
function showCustomDonationAmount(type) {
donationState[type].customAmount = true;
var customInput = document.getElementById('custom-amount-' + type);
customInput.classList.remove('hidden');
var buttons = document.querySelectorAll('[id^="amount-btn-' + type + '-"]');
buttons.forEach(function(btn) {
btn.classList.remove('border-[#4641D9]', 'text-[#4641D9]');
btn.classList.add('border-gray-200', 'text-gray-700');
});
var customBtn = document.getElementById('amount-btn-' + type + '-custom');
customBtn.classList.add('border-[#4641D9]', 'text-[#4641D9]');
customBtn.classList.remove('border-gray-200', 'text-gray-700');
}
function selectDonationType(type, donationType) {
donationState[type].donationType = donationType;
var buttons = document.querySelectorAll('[id^="donation-type-' + type + '-"]');
buttons.forEach(function(btn) {
btn.classList.remove('border-[#4641D9]', 'bg-[#4641D9]', 'text-white');
btn.classList.add('border-gray-200', 'text-gray-700');
});
var selected = document.getElementById('donation-type-' + type + '-' + donationType);
selected.classList.add('border-[#4641D9]', 'bg-[#4641D9]', 'text-white');
selected.classList.remove('border-gray-200', 'text-gray-700');
}
function selectDonationCurrency(type, currency) {
donationState[type].currency = currency;
var buttons = document.querySelectorAll('[id^="currency-' + type + '-"]');
buttons.forEach(function(btn) {
btn.classList.remove('border-[#4641D9]', 'bg-[#4641D9]', 'text-white');
btn.classList.add('border-gray-200', 'text-gray-700');
});
var selected = document.getElementById('currency-' + type + '-' + currency);
selected.classList.add('border-[#4641D9]', 'bg-[#4641D9]', 'text-white');
selected.classList.remove('border-gray-200', 'text-gray-700');
}
async function submitDonation(type) {
var email = document.getElementById('donation-email-' + type).value;
var errorEl = document.getElementById('donation-error-' + type);
errorEl.classList.add('hidden');
if (!email || !email.includes('@')) {
errorEl.textContent = '${escapeInlineScriptValue(i18n.errorInvalidEmail)}';
errorEl.classList.remove('hidden');
return;
}
var amount = donationState[type].customAmount
? parseInt(document.getElementById('custom-amount-' + type).value, 10)
: donationState[type].amount;
if (!amount || amount < 5 || amount > 1000) {
errorEl.textContent = '${escapeInlineScriptValue(i18n.errorInvalidAmount)}';
errorEl.classList.remove('hidden');
return;
}
var btn = document.getElementById('donate-btn-' + type);
btn.disabled = true;
btn.textContent = '${escapeInlineScriptValue(i18n.processing)}';
try {
var interval = donationState[type].donationType === 'once' ? null : donationState[type].donationType;
var response = await fetch('${apiEndpoint}/donations/checkout', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
email: email,
amount_cents: amount * 100,
currency: donationState[type].currency,
interval: interval
})
});
if (response.ok) {
var data = await response.json();
window.location.href = data.url;
} else {
var error = await response.json();
errorEl.textContent = error.message || '${escapeInlineScriptValue(i18n.errorGeneric)}';
errorEl.classList.remove('hidden');
}
} catch (err) {
errorEl.textContent = '${escapeInlineScriptValue(i18n.errorNetwork)}';
errorEl.classList.remove('hidden');
}
btn.disabled = false;
btn.textContent = '${escapeInlineScriptValue(i18n.donate)}';
}`,
}}
/>
);
}
const SWISH_URL =
'swish://payment?data=%7B%22version%22%3A1%2C%22payee%22%3A%7B%22value%22%3A%221232376820%22%2C%22editable%22%3Afalse%7D%2C%22amount%22%3A%7B%22value%22%3A50%2C%22editable%22%3Atrue%7D%2C%22message%22%3A%7B%22value%22%3A%22Fluxer%20Donation%22%2C%22editable%22%3Afalse%7D%7D';
const DONATE_BTN_SECONDARY =
'flex h-12 w-full items-center justify-center gap-2 rounded-xl border border-gray-200 bg-white px-6 font-semibold text-gray-900 transition-colors hover:bg-gray-50';