This commit is contained in:
Vincent van der Wal
2026-08-07 10:58:30 +02:00
parent 83b0e46e64
commit 032b652675
10 changed files with 85 additions and 61 deletions
+6 -3
View File
@@ -14,7 +14,8 @@
import { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
import SupporterBadge from '$lib/supporter/SupporterBadge.svelte';
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
import SettingsMenu from './settings-menu.svelte';
import ThemeIcon from './theme-icon.svelte';
@@ -158,8 +159,10 @@
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
so SupporterBadge still verifies the key on every viewport. -->
<div class="hidden items-center gap-3 md:flex">
<!-- Supporter status / unlock -->
<SupporterBadge />
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<SupporterBadge />
{/if}
<!-- Language: the locale lives in the URL, so this is a set of links -->
<LanguageSelector />
@@ -6,9 +6,10 @@
import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages';
import SupporterIcon from '$lib/paywall/SupporterIcon.svelte';
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import SupporterIcon from '$lib/supporter/SupporterIcon.svelte';
import UnlockDialog from '$lib/supporter/UnlockDialog.svelte';
import { SUPPORTER_ENABLED } from '$lib/supporter/config';
import { isSupporter } from '$lib/supporter/store';
import ThemeIcon from './theme-icon.svelte';
@@ -76,29 +77,34 @@
<LanguageOptions onSelect={() => (open = false)} />
<div class="border-t border-border/70 pt-3">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onclick={() => {
open = false;
unlockOpen = true;
}}
>
<SupporterIcon
filled={$isSupporter}
class="h-4.5 w-4.5 shrink-0 {$isSupporter ? 'text-amber-500' : 'text-muted-foreground'}"
/>
<span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold">
{$isSupporter ? m.supporter_active() : m.supporter_support()}
<!-- Supporter status / unlock (hidden while supporter features are parked) -->
{#if SUPPORTER_ENABLED}
<div class="border-t border-border/70 pt-3">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onclick={() => {
open = false;
unlockOpen = true;
}}
>
<SupporterIcon
filled={$isSupporter}
class="h-4.5 w-4.5 shrink-0 {$isSupporter
? 'text-amber-500'
: 'text-muted-foreground'}"
/>
<span class="min-w-0 flex-1">
<span class="block text-[13px] font-semibold">
{$isSupporter ? m.supporter_active() : m.supporter_support()}
</span>
<span class="block text-[11px] text-muted-foreground">
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
</span>
</span>
<span class="block text-[11px] text-muted-foreground">
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()}
</span>
</span>
</button>
</div>
</button>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
@@ -5,7 +5,7 @@
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { isSupporter, refreshSupporter } from './supporter';
import { isSupporter, refreshSupporter } from './store';
let open = $state(false);
@@ -6,7 +6,7 @@
import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
import { isSupporter, refreshSupporter, supporterState } from './supporter';
import { isSupporter, refreshSupporter, supporterState } from './store';
interface Props {
/** Short feature name shown in the locked panel headline. */
@@ -2,7 +2,7 @@
/**
* The supporter mark. A heart rather than a padlock: nothing here is locked
* away as a punishment - the extras are a thank-you for chipping in, and a
* padlock framed it as a paywall. Filled once someone is supporting,
* padlock framed it as a hard wall. Filled once someone is supporting,
* outlined as an invitation before that.
*/
interface Props {
@@ -9,13 +9,7 @@
import * as m from '$lib/paraglide/messages';
import { SIGNUP_URL, getSupporterPrice } from './config';
import {
clearLicense,
isSupporter,
storedLicenseKey,
supporterState,
verifyKey
} from './supporter';
import { clearLicense, isSupporter, storedLicenseKey, supporterState, verifyKey } from './store';
interface Props {
open?: boolean;
@@ -1,29 +1,40 @@
import * as m from '$lib/paraglide/messages';
/**
* Paywall configuration.
* Supporter configuration.
*
* The frontend stays fully static and open source; the only server piece is the
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used.
* build at your deployment with the `VITE_SUPPORTER_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used. The
* pre-rename `VITE_PAYWALL_*` names still work.
*/
const env = import.meta.env as Record<string, string | undefined>;
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
/**
* Master switch for the supporter features. While this is `false` everything
* supporter-related is parked: the nav entry points are hidden, no key is
* verified, and every gated page renders as if the visitor were a supporter.
* The logic stays in place set `VITE_SUPPORTER_ENABLED=true` (or default this
* to `true`) to bring it back.
*/
export const SUPPORTER_ENABLED = env.VITE_SUPPORTER_ENABLED === 'true';
/** Base URL of the self-hosted verify API (drizzli-paywall). */
export const PAYWALL_API_BASE = stripTrailingSlash(
env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
export const SUPPORTER_API_BASE = stripTrailingSlash(
env.VITE_SUPPORTER_API_BASE ?? env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li'
);
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
/** Where prospective supporters go to sign up (the verify API's signup form). */
export const SIGNUP_URL =
env.VITE_SUPPORTER_SIGNUP_URL ?? env.VITE_PAYWALL_SIGNUP_URL ?? `${SUPPORTER_API_BASE}/`;
// ─── Location-based pricing ──────────────────────────────────────────────────
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
// from their location. Mirror that here so the paywall copy matches. Detection
// from their location. Mirror that here so the supporter copy matches. Detection
// is timezone/locale based (no network geo lookup) and guarded for SSR.
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
@@ -54,7 +65,7 @@ export function detectCurrency(): SupporterCurrency {
return 'EUR';
}
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / Monat". */
/** Display price for the locked panel, e.g. "€3 / month" or "CHF 3 / Monat". */
export function getSupporterPrice(): string {
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
// deployment's .env keeps working.
@@ -8,7 +8,7 @@
*
* This gate is a convenience/honor-system gate: the frontend is open source and
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
* paywall repo) is what makes it meaningful in practice.
* verify API's repo) is what makes it meaningful in practice.
*/
import { derived, get, writable } from 'svelte/store';
@@ -16,7 +16,7 @@ import { persisted } from 'svelte-persisted-store';
import * as m from '$lib/paraglide/messages';
import { PAYWALL_API_BASE } from './config';
import { SUPPORTER_API_BASE, SUPPORTER_ENABLED } from './config';
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
export const storedLicenseKey = persisted<string>('license_key', '');
@@ -75,10 +75,14 @@ function notExpired(expires: string | null | undefined): boolean {
* Whether supporter content should be shown. A live "valid"/"invalid" result
* wins; otherwise we fall back to the cached result (so a reload or a brief
* network blip doesn't lock a paying user out).
*
* While supporter features are parked (`SUPPORTER_ENABLED === false`) this is always
* true, so every gated page renders unlocked for everyone.
*/
export const isSupporter = derived(
[supporterState, storedSupporterCache],
([$state, $cache]): boolean => {
if (!SUPPORTER_ENABLED) return true;
if ($state.status === 'valid') return true;
if ($state.status === 'invalid') return false;
return !!($cache && $cache.valid && notExpired($cache.expires));
@@ -98,6 +102,10 @@ export interface VerifyResult {
* subscription can still show a "renew" state) but the cache is marked invalid.
*/
export async function verifyKey(key: string): Promise<VerifyResult> {
// Parked: never touch the verify API (and report success, since everything
// is unlocked anyway).
if (!SUPPORTER_ENABLED) return { valid: true, expires: null };
const trimmed = key.trim();
if (!trimmed) {
supporterState.set({ status: 'invalid' });
@@ -106,7 +114,7 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
supporterState.set({ status: 'checking' });
try {
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
const res = await fetch(`${SUPPORTER_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
headers: { accept: 'application/json' }
});
const data = (await res.json()) as VerifyResult;
@@ -135,6 +143,8 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
export async function refreshSupporter(): Promise<void> {
if (!SUPPORTER_ENABLED) return;
const key = get(storedLicenseKey);
if (!key) {
supporterState.set({ status: 'idle' });
@@ -18,14 +18,14 @@
import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type HistoricalForecastResult,
fetchClimateNormals,
fetchHistoricalWeather
} from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte';
import { archiveModelGroups, defaultParameters } from '../../options';
@@ -43,7 +43,7 @@
// The page cross-fade waits for this before revealing the new page - and so
// does the loading overlay, so a visitor without a key has to count as ready:
// the paywall is the finished page here, nothing is on its way.
// the locked panel is the finished page here, nothing is on its way.
reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -243,7 +243,7 @@
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head>
<PaywallGate feature={m.page_historical_subtitle()}>
<SupporterGate feature={m.page_historical_subtitle()}>
<DateRangeControls
start={startDate}
end={endDate}
@@ -290,4 +290,4 @@
</div>
{/if}
</div>
</PaywallGate>
</SupporterGate>
@@ -13,14 +13,14 @@
import { Switch } from '$lib/components/ui/switch';
import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import {
type ClimateNormals,
type SeasonalForecastResult,
fetchClimateNormals,
fetchSeasonalForecast
} from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, seasonalModelGroups } from '../../options';
@@ -37,7 +37,7 @@
// The page cross-fade waits for this before revealing the new page - and so
// does the loading overlay, so a visitor without a key has to count as ready:
// the paywall is the finished page here, nothing is on its way.
// the locked panel is the finished page here, nothing is on its way.
reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions);
@@ -202,7 +202,7 @@
mounting them on arrival re-flowed the row and nudged the heading.
Two exceptions, both of which would reserve a gap for something that
is never coming: below sm the group is a full-width row of its own,
and behind the paywall there is no forecast on its way at all. -->
and behind the supporter gate there is no forecast on its way at all. -->
{#if $isSupporter}
<div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto {result
@@ -269,7 +269,7 @@
</p>
</div>
<PaywallGate feature={m.page_seasonal_subtitle()}>
<SupporterGate feature={m.page_seasonal_subtitle()}>
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -318,4 +318,4 @@
</div>
{/if}
</div>
</PaywallGate>
</SupporterGate>