move settings to one icon mobile
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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
|
||||
* gated content shows instantly on reload (and keeps working briefly offline)
|
||||
* without waiting for the network round-trip.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
|
||||
import { PAYWALL_API_BASE } from './config';
|
||||
|
||||
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
||||
export const storedLicenseKey = persisted<string>('license_key', '');
|
||||
|
||||
export interface SupporterCache {
|
||||
valid: boolean;
|
||||
tier?: string;
|
||||
/** ISO date the subscription lapses, or null for a lifetime key. */
|
||||
expires?: string | null;
|
||||
/** epoch ms of the last verify call */
|
||||
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 storedSupporterCache = persisted<SupporterCache | null>(CACHE_KEY, readLegacyCache());
|
||||
|
||||
export type SupporterStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||
|
||||
export interface SupporterState {
|
||||
status: SupporterStatus;
|
||||
tier?: string;
|
||||
expires?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Live verification state for the current session. */
|
||||
export const supporterState = writable<SupporterState>({ status: 'idle' });
|
||||
|
||||
function notExpired(expires: string | null | undefined): boolean {
|
||||
if (!expires) return true; // lifetime key
|
||||
const t = Date.parse(expires);
|
||||
return Number.isFinite(t) && t > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 isSupporter = derived(
|
||||
[supporterState, storedSupporterCache],
|
||||
([$state, $cache]): boolean => {
|
||||
if ($state.status === 'valid') return true;
|
||||
if ($state.status === 'invalid') return false;
|
||||
return !!($cache && $cache.valid && notExpired($cache.expires));
|
||||
}
|
||||
);
|
||||
|
||||
export interface VerifyResult {
|
||||
valid: boolean;
|
||||
tier?: string;
|
||||
expires?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a key against the API. On success the key is persisted and the cache
|
||||
* updated. On an invalid key the stored key is left untouched (so an expired
|
||||
* subscription can still show a "renew" state) but the cache is marked invalid.
|
||||
*/
|
||||
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
supporterState.set({ status: 'invalid' });
|
||||
return { valid: false, error: 'Enter your access key.' };
|
||||
}
|
||||
|
||||
supporterState.set({ status: 'checking' });
|
||||
try {
|
||||
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||
headers: { accept: 'application/json' }
|
||||
});
|
||||
const data = (await res.json()) as VerifyResult;
|
||||
|
||||
if (res.ok && data.valid) {
|
||||
storedLicenseKey.set(trimmed);
|
||||
storedSupporterCache.set({
|
||||
valid: true,
|
||||
tier: data.tier,
|
||||
expires: data.expires ?? null,
|
||||
checkedAt: Date.now()
|
||||
});
|
||||
supporterState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
||||
return { valid: true, tier: data.tier, expires: data.expires ?? null };
|
||||
}
|
||||
|
||||
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);
|
||||
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 refreshSupporter(): Promise<void> {
|
||||
const key = get(storedLicenseKey);
|
||||
if (!key) {
|
||||
supporterState.set({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
await verifyKey(key);
|
||||
}
|
||||
|
||||
/** Forget the key and supporter state ("sign out"). */
|
||||
export function clearLicense(): void {
|
||||
storedLicenseKey.set('');
|
||||
storedSupporterCache.set(null);
|
||||
supporterState.set({ status: 'idle' });
|
||||
}
|
||||
Reference in New Issue
Block a user