move settings to one icon mobile
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
{ href: resolve('/weather/week'), label: '7-Day Forecast' },
|
||||
{ href: resolve('/weather/compare'), label: 'Model Comparison' },
|
||||
{ href: resolve('/weather/14-day'), label: '14-Day Forecast' },
|
||||
{ href: resolve('/weather/seasonal'), label: 'Seasonal Outlook' },
|
||||
{ href: resolve('/weather/historical'), label: 'Historical Weather' },
|
||||
{ href: resolve('/weather/maps'), label: 'Weather Maps' }
|
||||
];
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||
|
||||
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
|
||||
import SupporterBadge from '$lib/paywall/SupporterBadge.svelte';
|
||||
|
||||
import SettingsMenu from './settings-menu.svelte';
|
||||
import ThemeIcon from './theme-icon.svelte';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle?: () => void;
|
||||
@@ -60,6 +63,8 @@
|
||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/historical')) {
|
||||
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
||||
} else if (currentPath.startsWith('/weather/seasonal')) {
|
||||
goto(resolve('/weather/seasonal/[location]', { location: locationRoute }));
|
||||
} else {
|
||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||
}
|
||||
@@ -117,63 +122,31 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Measurement units -->
|
||||
<UnitSelector />
|
||||
<!-- Phones only have room for one control, so units, theme and supporter
|
||||
status collapse into a single settings menu below md. -->
|
||||
<div class="md:hidden">
|
||||
<SettingsMenu />
|
||||
</div>
|
||||
|
||||
<!-- Premium status / unlock -->
|
||||
<PremiumBadge />
|
||||
<!-- 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">
|
||||
<!-- Measurement units -->
|
||||
<UnitSelector />
|
||||
|
||||
<!-- Theme toggle: system → light → dark -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={cycleTheme}
|
||||
title={themeTitles[$storedTheme]}
|
||||
aria-label={themeTitles[$storedTheme]}
|
||||
>
|
||||
{#if $storedTheme === 'light'}
|
||||
<!-- sun -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $storedTheme === 'dark'}
|
||||
<!-- moon -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- monitor (system) -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Supporter status / unlock -->
|
||||
<SupporterBadge />
|
||||
|
||||
<!-- Theme toggle: system → light → dark -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={cycleTheme}
|
||||
title={themeTitles[$storedTheme]}
|
||||
aria-label={themeTitles[$storedTheme]}
|
||||
>
|
||||
<ThemeIcon theme={$storedTheme} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { type Theme, storedTheme } from '$lib/stores/settings';
|
||||
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||
|
||||
import UnlockDialog from '$lib/paywall/UnlockDialog.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
|
||||
import ThemeIcon from './theme-icon.svelte';
|
||||
|
||||
// The topbar has room for one control on a phone, so units, theme and the
|
||||
// supporter status share this menu instead of each carrying its own trigger.
|
||||
const THEMES: { value: Theme; label: string }[] = [
|
||||
{ value: 'system', label: 'System' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' }
|
||||
];
|
||||
|
||||
let open = $state(false);
|
||||
let unlockOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
>
|
||||
<!-- gear -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2v.2a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.2-2.9H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"
|
||||
/>
|
||||
</svg>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content align="end" class="w-72 border-border">
|
||||
<div class="flex flex-col gap-4">
|
||||
<UnitOptions />
|
||||
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Theme
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each THEMES as option (option.value)}
|
||||
{@const active = $storedTheme === option.value}
|
||||
<button
|
||||
class="flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={active}
|
||||
onclick={() => storedTheme.set(option.value)}
|
||||
>
|
||||
<ThemeIcon theme={option.value} class="h-4 w-4" />
|
||||
{option.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;
|
||||
}}
|
||||
>
|
||||
{#if $isSupporter}
|
||||
<!-- star -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5 shrink-0 text-amber-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- padlock -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5 shrink-0 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block text-[13px] font-semibold">
|
||||
{$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
</span>
|
||||
<span class="block text-[11px] text-muted-foreground">
|
||||
{$isSupporter ? 'Manage your access key' : 'Unlock the supporter extras'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<UnlockDialog bind:open={unlockOpen} />
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Theme } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { theme, class: className = 'h-4.5 w-4.5' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if theme === 'light'}
|
||||
<!-- sun -->
|
||||
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
|
||||
/>
|
||||
</svg>
|
||||
{:else if theme === 'dark'}
|
||||
<!-- moon -->
|
||||
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- monitor (system) -->
|
||||
<svg class={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -30,6 +30,12 @@
|
||||
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Seasonal',
|
||||
url: '/weather/seasonal' as const,
|
||||
// rising trend line (long-range outlook)
|
||||
iconPaths: ['M3 17l6-6 4 4 7-7', 'M16 8h5v5']
|
||||
},
|
||||
{
|
||||
title: 'Historical',
|
||||
url: '/weather/historical' as const,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
// each group maps a stored unit key to its selectable options
|
||||
const UNIT_GROUPS: {
|
||||
key: keyof UnitPrefs;
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'temperature_unit',
|
||||
label: 'Temperature',
|
||||
options: [
|
||||
{ value: 'celsius', label: '°C' },
|
||||
{ value: 'fahrenheit', label: '°F' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'wind_speed_unit',
|
||||
label: 'Wind speed',
|
||||
options: [
|
||||
{ value: 'kmh', label: 'km/h' },
|
||||
{ value: 'ms', label: 'm/s' },
|
||||
{ value: 'mph', label: 'mph' },
|
||||
{ value: 'kn', label: 'kn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'precipitation_unit',
|
||||
label: 'Precipitation',
|
||||
options: [
|
||||
{ value: 'mm', label: 'mm' },
|
||||
{ value: 'inch', label: 'inch' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function setUnit(key: keyof UnitPrefs, value: string) {
|
||||
storedUnits.update((u) => ({ ...u, [key]: value }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each UNIT_GROUPS as group (group.key)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each group.options as opt (opt.value)}
|
||||
{@const active = $storedUnits[group.key] === opt.value}
|
||||
<button
|
||||
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={active}
|
||||
onclick={() => setUnit(group.key, opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,45 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
|
||||
// each group maps a stored unit key to its selectable options
|
||||
const UNIT_GROUPS: {
|
||||
key: keyof UnitPrefs;
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'temperature_unit',
|
||||
label: 'Temperature',
|
||||
options: [
|
||||
{ value: 'celsius', label: '°C' },
|
||||
{ value: 'fahrenheit', label: '°F' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'wind_speed_unit',
|
||||
label: 'Wind speed',
|
||||
options: [
|
||||
{ value: 'kmh', label: 'km/h' },
|
||||
{ value: 'ms', label: 'm/s' },
|
||||
{ value: 'mph', label: 'mph' },
|
||||
{ value: 'kn', label: 'kn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'precipitation_unit',
|
||||
label: 'Precipitation',
|
||||
options: [
|
||||
{ value: 'mm', label: 'mm' },
|
||||
{ value: 'inch', label: 'inch' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function setUnit(key: keyof UnitPrefs, value: string) {
|
||||
storedUnits.update((u) => ({ ...u, [key]: value }));
|
||||
}
|
||||
import UnitOptions from '$lib/components/unit-options.svelte';
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
@@ -65,30 +26,6 @@
|
||||
</svg>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end" class="w-64 border-border">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each UNIT_GROUPS as group (group.key)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each group.options as opt (opt.value)}
|
||||
{@const active = $storedUnits[group.key] === opt.value}
|
||||
<button
|
||||
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={active}
|
||||
onclick={() => setUnit(group.key, opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<UnitOptions />
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
|
||||
import { isPremium, premiumState, refreshPremium } from './premium';
|
||||
import { SUPPORTER_PERKS, SIGNUP_URL, getSupporterPrice } from './config';
|
||||
import { isSupporter, supporterState, refreshSupporter } from './supporter';
|
||||
|
||||
interface Props {
|
||||
/** Short feature name shown in the locked panel headline. */
|
||||
@@ -14,16 +14,16 @@
|
||||
let { feature = 'This page', children }: Props = $props();
|
||||
|
||||
let unlockOpen = $state(false);
|
||||
const price = getPremiumPrice();
|
||||
const price = getSupporterPrice();
|
||||
|
||||
// Re-verify the stored key whenever the gate mounts.
|
||||
onMount(refreshPremium);
|
||||
onMount(refreshSupporter);
|
||||
|
||||
// Show a brief spinner only when we have no cached answer yet and are checking.
|
||||
let initialChecking = $derived($premiumState.status === 'checking' && !$isPremium);
|
||||
let initialChecking = $derived($supporterState.status === 'checking' && !$isSupporter);
|
||||
</script>
|
||||
|
||||
{#if $isPremium}
|
||||
{#if $isSupporter}
|
||||
{@render children()}
|
||||
{:else if initialChecking}
|
||||
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
|
||||
@@ -59,7 +59,7 @@
|
||||
</p>
|
||||
|
||||
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
|
||||
{#each PREMIUM_PERKS as perk (perk)}
|
||||
{#each SUPPORTER_PERKS as perk (perk)}
|
||||
<li class="flex items-start gap-2.5 text-sm">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
||||
@@ -93,7 +93,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $premiumState.status === 'invalid'}
|
||||
{#if $supporterState.status === 'invalid'}
|
||||
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
|
||||
Your saved key is no longer valid or has expired.
|
||||
</p>
|
||||
|
||||
@@ -2,31 +2,31 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { isPremium, refreshPremium } from './premium';
|
||||
import { isSupporter, refreshSupporter } from './supporter';
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
// Verify once on load so the badge reflects real status site-wide.
|
||||
onMount(refreshPremium);
|
||||
onMount(refreshSupporter);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isPremium
|
||||
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isSupporter
|
||||
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
|
||||
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||
onclick={() => (open = true)}
|
||||
title={$isPremium ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
aria-label={$isPremium ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
title={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
aria-label={$isSupporter ? 'Supporter extras active' : 'Support Drizz.li'}
|
||||
>
|
||||
{#if $isPremium}
|
||||
{#if $isSupporter}
|
||||
<!-- star -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path
|
||||
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Premium</span>
|
||||
<span class="hidden sm:inline">Supporter</span>
|
||||
{:else}
|
||||
<!-- padlock -->
|
||||
<svg
|
||||
@@ -40,7 +40,7 @@
|
||||
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Premium</span>
|
||||
<span class="hidden sm:inline">Supporter</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import { SIGNUP_URL, getPremiumPrice } from './config';
|
||||
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
|
||||
import { SIGNUP_URL, getSupporterPrice } from './config';
|
||||
import { clearLicense, isSupporter, supporterState, storedLicenseKey, verifyKey } from './supporter';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
const price = getPremiumPrice();
|
||||
const price = getSupporterPrice();
|
||||
|
||||
// Prefill with the stored key so an existing subscriber sees their key.
|
||||
let keyInput = $state('');
|
||||
@@ -50,8 +50,8 @@
|
||||
}
|
||||
|
||||
let expiresLabel = $derived.by(() => {
|
||||
const exp = $premiumState.expires;
|
||||
if ($premiumState.status !== 'valid') return null;
|
||||
const exp = $supporterState.expires;
|
||||
if ($supporterState.status !== 'valid') return null;
|
||||
if (!exp) return 'Lifetime access';
|
||||
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
||||
});
|
||||
@@ -66,7 +66,7 @@
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if $isPremium && $premiumState.status === 'valid'}
|
||||
{#if $isSupporter && $supporterState.status === 'valid'}
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-lg border border-emerald-300/60 bg-emerald-50 px-3.5 py-3 text-sm text-emerald-800 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-200"
|
||||
>
|
||||
@@ -81,7 +81,7 @@
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Premium is active</p>
|
||||
<p class="font-semibold">Supporter extras are active</p>
|
||||
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@
|
||||
|
||||
{#if localError}
|
||||
<p class="text-sm text-destructive">{localError}</p>
|
||||
{:else if $premiumState.status === 'error'}
|
||||
{:else if $supporterState.status === 'error'}
|
||||
<p class="text-sm text-destructive">
|
||||
Couldn't reach the server. Check your connection and try again.
|
||||
</p>
|
||||
@@ -114,7 +114,7 @@
|
||||
</form>
|
||||
|
||||
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
|
||||
{#if $isPremium}
|
||||
{#if $isSupporter}
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
|
||||
@@ -28,9 +28,9 @@ const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CH
|
||||
const US_ZONES =
|
||||
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
|
||||
|
||||
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
|
||||
export type SupporterCurrency = 'EUR' | 'USD' | 'CHF';
|
||||
|
||||
export function detectCurrency(): PremiumCurrency {
|
||||
export function detectCurrency(): SupporterCurrency {
|
||||
if (typeof Intl === 'undefined') return 'EUR';
|
||||
let tz = '';
|
||||
try {
|
||||
@@ -53,15 +53,19 @@ export function detectCurrency(): PremiumCurrency {
|
||||
}
|
||||
|
||||
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
|
||||
export function getPremiumPrice(): string {
|
||||
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
|
||||
export function getSupporterPrice(): string {
|
||||
// VITE_PREMIUM_PRICE is the pre-rename name, still honoured so an existing
|
||||
// deployment's .env keeps working.
|
||||
const configured = env.VITE_SUPPORTER_PRICE ?? env.VITE_PREMIUM_PRICE;
|
||||
if (configured) return configured;
|
||||
const currency = detectCurrency();
|
||||
const symbol = CURRENCY_SYMBOL[currency];
|
||||
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
||||
}
|
||||
|
||||
/** Short, human list of what supporting unlocks (shown on the locked panel). */
|
||||
export const PREMIUM_PERKS = [
|
||||
export const SUPPORTER_PERKS = [
|
||||
'Historical weather & climate-normal comparisons',
|
||||
'Seasonal outlook: months ahead vs the climate normal',
|
||||
'New supporter extras as they land'
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Premium (subscription) state.
|
||||
* Supporter (subscription) state.
|
||||
*
|
||||
* The user pastes an access key once; it is stored locally and re-verified
|
||||
* against the self-hosted verify API on load. The last good result is cached so
|
||||
@@ -16,10 +16,10 @@ import { persisted } from 'svelte-persisted-store';
|
||||
|
||||
import { PAYWALL_API_BASE } from './config';
|
||||
|
||||
/** The subscriber'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 interface PremiumCache {
|
||||
export interface SupporterCache {
|
||||
valid: boolean;
|
||||
tier?: string;
|
||||
/** ISO date the subscription lapses, or null for a lifetime key. */
|
||||
@@ -28,20 +28,40 @@ export interface PremiumCache {
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'supporter_cache_v1';
|
||||
const LEGACY_CACHE_KEY = 'premium_cache_v1';
|
||||
|
||||
/**
|
||||
* Carries the pre-rename cache over on first load, so supporters who already
|
||||
* verified aren't shown a locked page while the key re-verifies.
|
||||
*/
|
||||
function readLegacyCache(): SupporterCache | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
try {
|
||||
if (localStorage.getItem(CACHE_KEY)) return null;
|
||||
const legacy = localStorage.getItem(LEGACY_CACHE_KEY);
|
||||
if (!legacy) return null;
|
||||
localStorage.removeItem(LEGACY_CACHE_KEY);
|
||||
return JSON.parse(legacy) as SupporterCache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
|
||||
export const storedPremiumCache = persisted<PremiumCache | null>('premium_cache_v1', null);
|
||||
export const storedSupporterCache = persisted<SupporterCache | null>(CACHE_KEY, readLegacyCache());
|
||||
|
||||
export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||
export type SupporterStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||
|
||||
export interface PremiumState {
|
||||
status: PremiumStatus;
|
||||
export interface SupporterState {
|
||||
status: SupporterStatus;
|
||||
tier?: string;
|
||||
expires?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Live verification state for the current session. */
|
||||
export const premiumState = writable<PremiumState>({ status: 'idle' });
|
||||
export const supporterState = writable<SupporterState>({ status: 'idle' });
|
||||
|
||||
function notExpired(expires: string | null | undefined): boolean {
|
||||
if (!expires) return true; // lifetime key
|
||||
@@ -50,12 +70,12 @@ function notExpired(expires: string | null | undefined): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether premium 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).
|
||||
* 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).
|
||||
*/
|
||||
export const isPremium = derived(
|
||||
[premiumState, storedPremiumCache],
|
||||
export const isSupporter = derived(
|
||||
[supporterState, storedSupporterCache],
|
||||
([$state, $cache]): boolean => {
|
||||
if ($state.status === 'valid') return true;
|
||||
if ($state.status === 'invalid') return false;
|
||||
@@ -78,11 +98,11 @@ export interface VerifyResult {
|
||||
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
premiumState.set({ status: 'invalid' });
|
||||
supporterState.set({ status: 'invalid' });
|
||||
return { valid: false, error: 'Enter your access key.' };
|
||||
}
|
||||
|
||||
premiumState.set({ status: 'checking' });
|
||||
supporterState.set({ status: 'checking' });
|
||||
try {
|
||||
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||
headers: { accept: 'application/json' }
|
||||
@@ -91,39 +111,39 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
|
||||
if (res.ok && data.valid) {
|
||||
storedLicenseKey.set(trimmed);
|
||||
storedPremiumCache.set({
|
||||
storedSupporterCache.set({
|
||||
valid: true,
|
||||
tier: data.tier,
|
||||
expires: data.expires ?? null,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
||||
supporterState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
||||
return { valid: true, tier: data.tier, expires: data.expires ?? null };
|
||||
}
|
||||
|
||||
storedPremiumCache.set({ valid: false, checkedAt: Date.now() });
|
||||
premiumState.set({ status: 'invalid' });
|
||||
storedSupporterCache.set({ valid: false, checkedAt: Date.now() });
|
||||
supporterState.set({ status: 'invalid' });
|
||||
return { valid: false };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
premiumState.set({ status: 'error', error: message });
|
||||
supporterState.set({ status: 'error', error: message });
|
||||
return { valid: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
|
||||
export async function refreshPremium(): Promise<void> {
|
||||
export async function refreshSupporter(): Promise<void> {
|
||||
const key = get(storedLicenseKey);
|
||||
if (!key) {
|
||||
premiumState.set({ status: 'idle' });
|
||||
supporterState.set({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
await verifyKey(key);
|
||||
}
|
||||
|
||||
/** Forget the key and premium state ("sign out"). */
|
||||
/** Forget the key and supporter state ("sign out"). */
|
||||
export function clearLicense(): void {
|
||||
storedLicenseKey.set('');
|
||||
storedPremiumCache.set(null);
|
||||
premiumState.set({ status: 'idle' });
|
||||
storedSupporterCache.set(null);
|
||||
supporterState.set({ status: 'idle' });
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
||||
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
||||
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||
const SEASONAL_URL = 'https://seasonal-api.open-meteo.com/v1/seasonal';
|
||||
|
||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1143,3 +1144,197 @@ export async function fetchClimateNormals(params: ClimateNormalsParams): Promise
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm'
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Seasonal (Long-Range) Types ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One daily variable of the seasonal ensemble: every member plus the spread
|
||||
* statistics the outlook renders (percentile band, mean, extremes).
|
||||
*/
|
||||
export interface SeasonalVariableData {
|
||||
/** Raw members, `members[m][t]`. */
|
||||
members: number[][];
|
||||
mean: number[];
|
||||
min: number[];
|
||||
max: number[];
|
||||
p25: number[];
|
||||
p75: number[];
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface SeasonalForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||
/** Daily API variables to request; defaults to SEASONAL_DAILY_VARS. */
|
||||
dailyVariables?: string[];
|
||||
/** Lead time in days; the API allows at most 216. */
|
||||
forecast_days?: number;
|
||||
}
|
||||
|
||||
export interface SeasonalForecastResult {
|
||||
variables: Record<string, SeasonalVariableData>;
|
||||
/** Milliseconds, one entry per day (already trimmed to the model's horizon). */
|
||||
timestamps: number[];
|
||||
/**
|
||||
* Local wall time (local midnight) expressed as a UTC instant - read these
|
||||
* with the UTC getters, never with the location's IANA zone. The seasonal API
|
||||
* keeps ONE offset for the whole series, so a half-year range that crosses a
|
||||
* DST change would otherwise land two days on the same local date.
|
||||
*/
|
||||
dailyDates: Date[];
|
||||
/** `YYYY-MM-DD` local calendar date per day, matching the API's own labels. */
|
||||
dateKeys: string[];
|
||||
memberCount: number;
|
||||
utcOffsetSeconds: number;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/** The API caps the lead time here; the model itself usually stops earlier. */
|
||||
export const SEASONAL_MAX_DAYS = 216;
|
||||
|
||||
/** Requested in this order; the daily block returns variables positionally. */
|
||||
export const SEASONAL_DAILY_VARS = [
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'temperature_2m_mean',
|
||||
'precipitation_sum',
|
||||
'wind_speed_10m_mean',
|
||||
'cloud_cover_mean'
|
||||
] as const;
|
||||
|
||||
// ─── Seasonal (Long-Range) Fetch ────────────────────────────────────────────
|
||||
|
||||
/** Linear-interpolated percentile over an already ascending array. */
|
||||
function percentileSorted(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return NaN;
|
||||
if (sorted.length === 1) return sorted[0];
|
||||
const pos = (sorted.length - 1) * p;
|
||||
const lo = Math.floor(pos);
|
||||
const hi = Math.ceil(pos);
|
||||
if (lo === hi) return sorted[lo];
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the seasonal (multi-month) ensemble outlook from Open-Meteo's
|
||||
* seasonal API. Unlike the medium-range ensemble this is daily data: each
|
||||
* requested variable comes back once per member, so the members are collapsed
|
||||
* into the spread statistics the outlook page plots.
|
||||
*
|
||||
* The requested lead time is only an upper bound - the model's own horizon is
|
||||
* shorter, and every day past it comes back empty. Those trailing days are
|
||||
* trimmed here so callers never plot a flat-lined tail.
|
||||
*/
|
||||
export async function fetchSeasonalForecast(
|
||||
params: SeasonalForecastParams
|
||||
): Promise<SeasonalForecastResult> {
|
||||
const dailyVars =
|
||||
params.dailyVariables && params.dailyVariables.length > 0
|
||||
? [...new Set(params.dailyVariables)]
|
||||
: [...SEASONAL_DAILY_VARS];
|
||||
|
||||
const apiParams: Record<string, string | number | undefined> = {
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
daily: dailyVars.join(','),
|
||||
forecast_days: Math.min(params.forecast_days ?? SEASONAL_MAX_DAYS, SEASONAL_MAX_DAYS),
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
timezone: params.timezone
|
||||
};
|
||||
|
||||
const cleanParams: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(apiParams)) {
|
||||
if (value !== undefined) cleanParams[key] = String(value);
|
||||
}
|
||||
|
||||
const responses = await fetchWeatherApi(SEASONAL_URL, cleanParams);
|
||||
const response = responses[0];
|
||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
||||
|
||||
const dailyBlock = response.daily()!;
|
||||
const allTimestamps = getTimestamps(dailyBlock);
|
||||
const timeLength = allTimestamps.length;
|
||||
|
||||
// Members are laid out like the ensemble API: var0_member0 … var0_memberM-1,
|
||||
// var1_member0 …, so the count follows from the totals instead of being
|
||||
// hard-coded (it differs per seasonal model).
|
||||
const totalVariables = dailyBlock.variablesLength();
|
||||
const memberCount = dailyVars.length > 0 ? Math.floor(totalVariables / dailyVars.length) : 0;
|
||||
|
||||
const variables: Record<string, SeasonalVariableData> = {};
|
||||
|
||||
for (let vi = 0; vi < dailyVars.length; vi++) {
|
||||
const members: number[][] = [];
|
||||
let unitStr = '';
|
||||
|
||||
for (let mi = 0; mi < memberCount; mi++) {
|
||||
const variable = dailyBlock.variables(vi * memberCount + mi);
|
||||
if (!variable) continue;
|
||||
members.push(getValues(variable));
|
||||
if (mi === 0) unitStr = unitToDisplayString(variable.unit());
|
||||
}
|
||||
|
||||
const mean = new Array<number>(timeLength).fill(NaN);
|
||||
const min = new Array<number>(timeLength).fill(NaN);
|
||||
const max = new Array<number>(timeLength).fill(NaN);
|
||||
const p25 = new Array<number>(timeLength).fill(NaN);
|
||||
const p75 = new Array<number>(timeLength).fill(NaN);
|
||||
|
||||
for (let t = 0; t < timeLength; t++) {
|
||||
const values: number[] = [];
|
||||
for (const memberValues of members) {
|
||||
const val = memberValues[t];
|
||||
if (val != null && Number.isFinite(val)) values.push(val);
|
||||
}
|
||||
if (values.length === 0) continue;
|
||||
values.sort((a, b) => a - b);
|
||||
mean[t] = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
min[t] = values[0];
|
||||
max[t] = values[values.length - 1];
|
||||
p25[t] = percentileSorted(values, 0.25);
|
||||
p75[t] = percentileSorted(values, 0.75);
|
||||
}
|
||||
|
||||
variables[dailyVars[vi]] = { members, mean, min, max, p25, p75, unit: unitStr };
|
||||
}
|
||||
|
||||
// Past the model's horizon every member is empty (or padded to a constant
|
||||
// zero); cut the axis at the last day that carries real spread.
|
||||
const sentinel = variables[dailyVars[0]];
|
||||
let validLength = timeLength;
|
||||
if (sentinel) {
|
||||
let last = 0;
|
||||
for (let t = 0; t < timeLength; t++) {
|
||||
const hasSpread = !(sentinel.min[t] === 0 && sentinel.max[t] === 0);
|
||||
if (Number.isFinite(sentinel.mean[t]) && hasSpread) last = t + 1;
|
||||
}
|
||||
validLength = last || timeLength;
|
||||
}
|
||||
|
||||
if (validLength < timeLength) {
|
||||
for (const data of Object.values(variables)) {
|
||||
data.members = data.members.map((m) => m.slice(0, validLength));
|
||||
data.mean = data.mean.slice(0, validLength);
|
||||
data.min = data.min.slice(0, validLength);
|
||||
data.max = data.max.slice(0, validLength);
|
||||
data.p25 = data.p25.slice(0, validLength);
|
||||
data.p75 = data.p75.slice(0, validLength);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamps = allTimestamps.slice(0, validLength);
|
||||
// Shifted by the response's single offset (not the IANA zone) so each day
|
||||
// carries the exact local date the API labelled it with.
|
||||
const dailyDates = timestamps.map((t) => new Date(t + utcOffsetSeconds * 1000));
|
||||
|
||||
return {
|
||||
variables,
|
||||
timestamps,
|
||||
dailyDates,
|
||||
dateKeys: dailyDates.map((d) => d.toISOString().slice(0, 10)),
|
||||
memberCount,
|
||||
utcOffsetSeconds,
|
||||
timezone
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user