park
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user