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 { href, routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages'; 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 SettingsMenu from './settings-menu.svelte';
import ThemeIcon from './theme-icon.svelte'; import ThemeIcon from './theme-icon.svelte';
@@ -158,8 +159,10 @@
<!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`) <!-- md+: the same settings as individual controls. Kept mounted (not `{#if}`)
so SupporterBadge still verifies the key on every viewport. --> so SupporterBadge still verifies the key on every viewport. -->
<div class="hidden items-center gap-3 md:flex"> <div class="hidden items-center gap-3 md:flex">
<!-- Supporter status / unlock --> <!-- Supporter status / unlock (hidden while supporter features are parked) -->
<SupporterBadge /> {#if SUPPORTER_ENABLED}
<SupporterBadge />
{/if}
<!-- Language: the locale lives in the URL, so this is a set of links --> <!-- Language: the locale lives in the URL, so this is a set of links -->
<LanguageSelector /> <LanguageSelector />
@@ -6,9 +6,10 @@
import UnitOptions from '$lib/components/unit-options.svelte'; import UnitOptions from '$lib/components/unit-options.svelte';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import SupporterIcon from '$lib/paywall/SupporterIcon.svelte'; import SupporterIcon from '$lib/supporter/SupporterIcon.svelte';
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte'; import UnlockDialog from '$lib/supporter/UnlockDialog.svelte';
import { isSupporter } from '$lib/paywall/supporter'; import { SUPPORTER_ENABLED } from '$lib/supporter/config';
import { isSupporter } from '$lib/supporter/store';
import ThemeIcon from './theme-icon.svelte'; import ThemeIcon from './theme-icon.svelte';
@@ -76,29 +77,34 @@
<LanguageOptions onSelect={() => (open = false)} /> <LanguageOptions onSelect={() => (open = false)} />
<div class="border-t border-border/70 pt-3"> <!-- Supporter status / unlock (hidden while supporter features are parked) -->
<button {#if SUPPORTER_ENABLED}
type="button" <div class="border-t border-border/70 pt-3">
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted" <button
onclick={() => { type="button"
open = false; class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
unlockOpen = true; onclick={() => {
}} open = false;
> unlockOpen = true;
<SupporterIcon }}
filled={$isSupporter} >
class="h-4.5 w-4.5 shrink-0 {$isSupporter ? 'text-amber-500' : 'text-muted-foreground'}" <SupporterIcon
/> filled={$isSupporter}
<span class="min-w-0 flex-1"> class="h-4.5 w-4.5 shrink-0 {$isSupporter
<span class="block text-[13px] font-semibold"> ? 'text-amber-500'
{$isSupporter ? m.supporter_active() : m.supporter_support()} : '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>
<span class="block text-[11px] text-muted-foreground"> </button>
{$isSupporter ? m.supporter_manage_key() : m.supporter_unlock()} </div>
</span> {/if}
</span>
</button>
</div>
</div> </div>
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
@@ -5,7 +5,7 @@
import SupporterIcon from './SupporterIcon.svelte'; import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte'; import UnlockDialog from './UnlockDialog.svelte';
import { isSupporter, refreshSupporter } from './supporter'; import { isSupporter, refreshSupporter } from './store';
let open = $state(false); let open = $state(false);
@@ -6,7 +6,7 @@
import SupporterIcon from './SupporterIcon.svelte'; import SupporterIcon from './SupporterIcon.svelte';
import UnlockDialog from './UnlockDialog.svelte'; import UnlockDialog from './UnlockDialog.svelte';
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config'; import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
import { isSupporter, refreshSupporter, supporterState } from './supporter'; import { isSupporter, refreshSupporter, supporterState } from './store';
interface Props { interface Props {
/** Short feature name shown in the locked panel headline. */ /** 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 * 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 * 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. * outlined as an invitation before that.
*/ */
interface Props { interface Props {
@@ -9,13 +9,7 @@
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import { SIGNUP_URL, getSupporterPrice } from './config'; import { SIGNUP_URL, getSupporterPrice } from './config';
import { import { clearLicense, isSupporter, storedLicenseKey, supporterState, verifyKey } from './store';
clearLicense,
isSupporter,
storedLicenseKey,
supporterState,
verifyKey
} from './supporter';
interface Props { interface Props {
open?: boolean; open?: boolean;
@@ -1,29 +1,40 @@
import * as m from '$lib/paraglide/messages'; 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 * 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 * 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 * build at your deployment with the `VITE_SUPPORTER_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used. * `.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 env = import.meta.env as Record<string, string | undefined>;
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, ''); 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). */ /** Base URL of the self-hosted verify API (drizzli-paywall). */
export const PAYWALL_API_BASE = stripTrailingSlash( export const SUPPORTER_API_BASE = stripTrailingSlash(
env.VITE_PAYWALL_API_BASE ?? 'https://support.drizz.li' 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). */ /** Where prospective supporters go to sign up (the verify API's signup form). */
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`; export const SIGNUP_URL =
env.VITE_SUPPORTER_SIGNUP_URL ?? env.VITE_PAYWALL_SIGNUP_URL ?? `${SUPPORTER_API_BASE}/`;
// ─── Location-based pricing ────────────────────────────────────────────────── // ─── Location-based pricing ──────────────────────────────────────────────────
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked // 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. // is timezone/locale based (no network geo lookup) and guarded for SSR.
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' }; const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
@@ -54,7 +65,7 @@ export function detectCurrency(): SupporterCurrency {
return 'EUR'; 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 { export function getSupporterPrice(): string {
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing // VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
// deployment's .env keeps working. // deployment's .env keeps working.
@@ -8,7 +8,7 @@
* *
* This gate is a convenience/honor-system gate: the frontend is open source and * 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 * 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'; 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 * 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. */ /** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
export const storedLicenseKey = persisted<string>('license_key', ''); 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 * 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 * wins; otherwise we fall back to the cached result (so a reload or a brief
* network blip doesn't lock a paying user out). * 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( export const isSupporter = derived(
[supporterState, storedSupporterCache], [supporterState, storedSupporterCache],
([$state, $cache]): boolean => { ([$state, $cache]): boolean => {
if (!SUPPORTER_ENABLED) return true;
if ($state.status === 'valid') return true; if ($state.status === 'valid') return true;
if ($state.status === 'invalid') return false; if ($state.status === 'invalid') return false;
return !!($cache && $cache.valid && notExpired($cache.expires)); 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. * subscription can still show a "renew" state) but the cache is marked invalid.
*/ */
export async function verifyKey(key: string): Promise<VerifyResult> { 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(); const trimmed = key.trim();
if (!trimmed) { if (!trimmed) {
supporterState.set({ status: 'invalid' }); supporterState.set({ status: 'invalid' });
@@ -106,7 +114,7 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
supporterState.set({ status: 'checking' }); supporterState.set({ status: 'checking' });
try { 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' } headers: { accept: 'application/json' }
}); });
const data = (await res.json()) as VerifyResult; 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. */ /** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
export async function refreshSupporter(): Promise<void> { export async function refreshSupporter(): Promise<void> {
if (!SUPPORTER_ENABLED) return;
const key = get(storedLicenseKey); const key = get(storedLicenseKey);
if (!key) { if (!key) {
supporterState.set({ status: 'idle' }); supporterState.set({ status: 'idle' });
@@ -18,14 +18,14 @@
import { ChartContainer } from '$lib/components/charts'; import { ChartContainer } from '$lib/components/charts';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import { import {
type ClimateNormals, type ClimateNormals,
type HistoricalForecastResult, type HistoricalForecastResult,
fetchClimateNormals, fetchClimateNormals,
fetchHistoricalWeather fetchHistoricalWeather
} from '$lib/services/weather'; } from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte'; import { useHeroActions } from '../../hero.svelte';
import { archiveModelGroups, defaultParameters } from '../../options'; import { archiveModelGroups, defaultParameters } from '../../options';
@@ -43,7 +43,7 @@
// The page cross-fade waits for this before revealing the new page - and so // 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: // 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); reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions); useHeroActions(heroActions);
@@ -243,7 +243,7 @@
<meta name="description" content="Past weather and climate-normal comparisons for any location" /> <meta name="description" content="Past weather and climate-normal comparisons for any location" />
</svelte:head> </svelte:head>
<PaywallGate feature={m.page_historical_subtitle()}> <SupporterGate feature={m.page_historical_subtitle()}>
<DateRangeControls <DateRangeControls
start={startDate} start={startDate}
end={endDate} end={endDate}
@@ -290,4 +290,4 @@
</div> </div>
{/if} {/if}
</div> </div>
</PaywallGate> </SupporterGate>
@@ -13,14 +13,14 @@
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
import { isSupporter } from '$lib/paywall/supporter';
import { import {
type ClimateNormals, type ClimateNormals,
type SeasonalForecastResult, type SeasonalForecastResult,
fetchClimateNormals, fetchClimateNormals,
fetchSeasonalForecast fetchSeasonalForecast
} from '$lib/services/weather'; } from '$lib/services/weather';
import SupporterGate from '$lib/supporter/SupporterGate.svelte';
import { isSupporter } from '$lib/supporter/store';
import { useHeroActions } from '../../hero.svelte'; import { useHeroActions } from '../../hero.svelte';
import { defaultParameters, seasonalModelGroups } from '../../options'; import { defaultParameters, seasonalModelGroups } from '../../options';
@@ -37,7 +37,7 @@
// The page cross-fade waits for this before revealing the new page - and so // 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: // 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); reportPageReady(() => result != null || loadError != null || !$isSupporter);
useHeroActions(heroActions); useHeroActions(heroActions);
@@ -202,7 +202,7 @@
mounting them on arrival re-flowed the row and nudged the heading. mounting them on arrival re-flowed the row and nudged the heading.
Two exceptions, both of which would reserve a gap for something that 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, 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} {#if $isSupporter}
<div <div
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto {result class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto {result
@@ -269,7 +269,7 @@
</p> </p>
</div> </div>
<PaywallGate feature={m.page_seasonal_subtitle()}> <SupporterGate feature={m.page_seasonal_subtitle()}>
{#if loadError} {#if loadError}
<div <div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive" 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> </div>
{/if} {/if}
</div> </div>
</PaywallGate> </SupporterGate>