Add a Settings → Pro & Support hub

One discoverable home for every paid extra, so the pricing model is legible
instead of scattered across the Sasquatch panel and each offer form:

- components/ProSupportScreen.tsx (Settings ?section=pro + a menu row): honest
  "prepaid, not a subscription" framing; Sasquatch Pro status (days left) +
  PRO_TIERS with per-day sats and a Go Pro / Extend button; Featured placement
  (PLATFORM_FEES x DURATION_TIERS with per-day + best-value); your currently
  featured Lab listings (featuredUntil); and the free paths (PoW listing, free
  AI FAQ).
- Distinct naming in copy: Sasquatch Pro (AI) vs Featured placement (visibility).
- Smoke test now walks /settings?section=pro.
This commit is contained in:
SkateSpots 2026-10-04 14:28:13 +07:00
parent 8cdc41cc15
commit 4cad201716
6 changed files with 230 additions and 3 deletions

View file

@ -542,6 +542,17 @@ session with **no other memory of this project**. So:
Pro access (`useSasquatchPro`) is granted by a provider-authored zap receipt,
and its **expiry is derived from the paid amount → tier**, never the
user-signed `pro-expiry` tag.
**Pro & Support hub (2026-10-04):** all paid extras now have one home —
**Settings → Pro & Support** (`?section=pro`, `components/ProSupportScreen.tsx`,
menu row + section in `SettingsPage`). It shows the honest "prepaid, not a
subscription" framing, **Sasquatch Pro** status (days left) + `PRO_TIERS` with
per-day sats + a "Go Pro / Extend" button (opens `SasquatchProDialog`), the
**Featured placement** types (`PLATFORM_FEES` × `DURATION_TIERS`, per-day +
best-value), **your currently-featured Lab listings** (`featuredUntil`), and
the **free paths** (PoW listing, free AI FAQ). Distinct naming is enforced in
copy: **Sasquatch Pro** (AI) vs **Featured placement** (visibility) — don't
call both "Pro". Not done: an actual discounted bundle SKU (would be a single
combined zap, no new rails). `/settings?section=pro` is in the smoke list.
- **Pro proofs STACK (fixed 2026-09-28).** `useSasquatchPro` now collects ALL
valid proofs and accumulates `expiry = max(expiry, proofTime) + tierDays`
(renewal semantics) — before, it took the **max** of

View file

@ -138,6 +138,7 @@ const ROUTES = [
'/settings',
'/settings?section=profile',
'/settings?section=coordinator',
'/settings?section=pro',
'/npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx',
'/stash',
'/collections',

View file

@ -0,0 +1,158 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronLeft, Crown, CheckCircle2, Sparkles, Cpu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/contexts/language';
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
import { PRO_TIERS } from '@/lib/sasquatchPro';
import { DURATION_TIERS, PLATFORM_FEES, FEE_META, tierPriceSats, tierPerDaySats, type FeeType } from '@/hooks/usePlatformFee';
import { SasquatchProDialog } from '@/components/SasquatchProDialog';
import { useLabProfiles } from '@/hooks/useLabProfiles';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { labProfilePath } from '@/lib/labRoutes';
import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { cn } from '@/lib/utils';
/**
* Settings → Pro & Support.
*
* One honest home for every paid extra: what it does, the duration tiers and
* effective per-day price, your current status, and the free alternatives.
* Nothing here holds funds — each purchase is a one-off Lightning payment.
*/
export function ProSupportScreen({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
const { isPro, expiresAt } = useSasquatchPro();
const { user } = useCurrentUser();
const { data: profiles } = useLabProfiles(100);
const [proOpen, setProOpen] = useState(false);
const now = Math.floor(Date.now() / 1000);
const daysLeft = isPro && expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 86400)) : 0;
const fmt = (secs: number) => new Date(secs * 1000).toLocaleDateString(dateLocaleCode(), { month: 'short', day: 'numeric', year: 'numeric' });
const featured = (profiles ?? []).filter((p) => p.pubkey === user?.pubkey && p.featuredUntil && p.featuredUntil > now);
return (
<div>
<div className="flex items-center gap-3 mb-5">
<button
onClick={onBack}
aria-label={t('common.back')}
className="flex items-center justify-center w-8 h-8 rounded-xl bg-white/5 hover:bg-white/10 active:scale-95 transition-all"
>
<ChevronLeft className="h-4 w-4 text-white" />
</button>
<h2 className="text-lg font-black uppercase tracking-wide text-white">{t('settingsUi.proSupport.title')}</h2>
</div>
<div className="space-y-4">
{/* Honest framing */}
<div className="rounded-2xl border border-[#ccff00]/20 bg-[#ccff00]/5 p-4">
<p className="flex items-center gap-2 text-sm font-bold text-[#ccff00]">
<CheckCircle2 className="h-4 w-4" /> {t('settingsUi.proSupport.prepaidTitle')}
</p>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">{t('settingsUi.proSupport.prepaidBody')}</p>
</div>
{/* Sasquatch Pro */}
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-500/15">
<Sparkles className="h-4 w-4 text-emerald-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-black uppercase tracking-wide text-white">{t('settingsUi.proSupport.sasquatchTitle')}</p>
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">{t('settingsUi.proSupport.sasquatchBenefit')}</p>
<p className={cn('mt-1 text-xs font-bold', isPro ? 'text-emerald-400' : 'text-gray-500')}>
{isPro
? expiresAt
? `${t('settingsUi.proSupport.activeUntil', { date: fmt(expiresAt) })} · ${t('settingsUi.proSupport.daysLeft', { count: daysLeft })}`
: t('settingsUi.proSupport.active')
: t('settingsUi.proSupport.notActive')}
</p>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
{PRO_TIERS.map((tier) => (
<div key={tier.id} className={cn('rounded-xl border p-2.5 text-center', tier.popular ? 'border-emerald-500/50 bg-emerald-500/10' : 'border-white/10')}>
<p className="text-xs font-bold text-white">{t(tier.labelKey)}</p>
<p className="mt-1 text-sm font-black text-white">{tier.sats.toLocaleString()}</p>
<p className="text-[10px] text-muted-foreground">{t('common.sats')} · {Math.round(tier.sats / tier.days)}/{t('settingsUi.proSupport.day')}</p>
{tier.badgeKey && <p className="mt-1 text-[10px] font-bold text-emerald-400">{t(tier.badgeKey)}</p>}
</div>
))}
</div>
<Button onClick={() => setProOpen(true)} className="mt-3 w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90">
<Crown className="h-4 w-4" /> {isPro ? t('settingsUi.proSupport.extend') : t('settingsUi.proSupport.goPro')}
</Button>
</div>
{/* Featured placement */}
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl bg-amber-500/15">
<Cpu className="h-4 w-4 text-amber-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-black uppercase tracking-wide text-white">{t('settingsUi.proSupport.featuredTitle')}</p>
<p className="mt-0.5 text-xs text-muted-foreground leading-relaxed">{t('settingsUi.proSupport.featuredBenefit')}</p>
</div>
</div>
<div className="mt-3 space-y-3">
{(Object.keys(PLATFORM_FEES) as FeeType[]).map((type) => (
<div key={type}>
<p className="text-xs font-bold text-gray-300">
<span className="mr-1">{FEE_META[type].emoji}</span>{FEE_META[type].label}
<span className="ml-2 font-normal text-muted-foreground">{FEE_META[type].benefit}</span>
</p>
<div className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-4">
{DURATION_TIERS.map((tier) => (
<div key={tier.id} className={cn('rounded-lg border p-2 text-center', tier.bestValue ? 'border-[#ccff00]/40 bg-[#ccff00]/5' : 'border-white/10')}>
<p className="text-[11px] font-bold text-white">{tier.label}</p>
<p className="text-xs font-black text-white">{tierPriceSats(type, tier).toLocaleString()}</p>
<p className="text-[10px] text-muted-foreground">{tierPerDaySats(type, tier).toLocaleString()}/{t('settingsUi.proSupport.day')}</p>
</div>
))}
</div>
</div>
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">{t('settingsUi.proSupport.featuredHow')}</p>
</div>
{/* Your featured listings */}
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
<p className="text-sm font-black uppercase tracking-wide text-white">{t('settingsUi.proSupport.yourFeatured')}</p>
{featured.length === 0 ? (
<p className="mt-1 text-xs text-muted-foreground">{t('settingsUi.proSupport.noneFeatured')}</p>
) : (
<div className="mt-2 space-y-1.5">
{featured.map((p) => (
<Link
key={p.id}
to={labProfilePath(p.profileType, p.pubkey, p.identifier)}
className="flex items-center justify-between gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-bold text-gray-200 hover:bg-white/10"
>
<span className="truncate">{p.title}</span>
<span className="flex-shrink-0 text-amber-400">{t('settingsUi.proSupport.featuredUntil', { date: fmt(p.featuredUntil!) })}</span>
</Link>
))}
</div>
)}
</div>
{/* Free paths */}
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
<p className="text-sm font-black uppercase tracking-wide text-white">{t('settingsUi.proSupport.freeTitle')}</p>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">{t('settingsUi.proSupport.freeBody')}</p>
</div>
</div>
<SasquatchProDialog open={proOpen} onOpenChange={setProOpen} />
</div>
);
}

View file

@ -4307,6 +4307,29 @@ socialUi: {
},
settingsUi: {
proSupport: {
title: 'Pro & Support',
subtitle: 'Optional extras that keep SkateSpots free',
prepaidTitle: 'Prepaid, not a subscription',
prepaidBody: 'Every purchase is a one-off Bitcoin/Lightning payment — no account, no recurring charge, no custody. Paying again extends your time.',
sasquatchTitle: 'Sasquatch Pro',
sasquatchBenefit: 'Unlocks the personal AI assistant — it can read your own Stash, crews and listings — and helps fund the project.',
active: 'Active',
activeUntil: 'Active until {{date}}',
daysLeft: '{{count}} days left',
notActive: 'Not active',
goPro: 'Go Pro',
extend: 'Extend Pro',
day: 'day',
featuredTitle: 'Featured placement',
featuredBenefit: 'Push a listing to the top with a Pro badge — for Lab profiles, events, shops and tours.',
featuredHow: 'Duration tiers apply to every type; longer commitments cost less per day. You feature a listing when you create or edit it.',
featuredUntil: 'Until {{date}}',
yourFeatured: 'Your featured listings',
noneFeatured: 'No active featured placements.',
freeTitle: 'Free paths',
freeBody: 'The AI FAQ and local search are always free, and you can publish a marketplace listing with a free proof-of-work instead of the sats fee.',
},
notFound: {
seoTitle: '404 - Page Not Found',
seoDescription: 'The page you are looking for could not be found. Return to the home page to continue browsing.',

View file

@ -4307,6 +4307,29 @@ socialUi: {
},
settingsUi: {
proSupport: {
title: 'Pro y apoyo',
subtitle: 'Extras opcionales que mantienen SkateSpots gratis',
prepaidTitle: 'Pago por adelantado, no suscripción',
prepaidBody: 'Cada compra es un pago único en Bitcoin/Lightning — sin cuenta, sin cargo recurrente y sin custodia. Pagar de nuevo amplía tu tiempo.',
sasquatchTitle: 'Sasquatch Pro',
sasquatchBenefit: 'Desbloquea el asistente personal con IA — puede leer tu Stash, crews y anuncios — y ayuda a financiar el proyecto.',
active: 'Activo',
activeUntil: 'Activo hasta {{date}}',
daysLeft: 'Quedan {{count}} días',
notActive: 'No activo',
goPro: 'Hazte Pro',
extend: 'Ampliar Pro',
day: 'día',
featuredTitle: 'Posición destacada',
featuredBenefit: 'Lleva un anuncio arriba con un distintivo Pro — para perfiles del Lab, eventos, tiendas y tours.',
featuredHow: 'Los tramos de duración se aplican a cada tipo; los compromisos más largos cuestan menos por día. Destacas un anuncio al crearlo o editarlo.',
featuredUntil: 'Hasta {{date}}',
yourFeatured: 'Tus anuncios destacados',
noneFeatured: 'No hay destacados activos.',
freeTitle: 'Opciones gratis',
freeBody: 'El FAQ de IA y la búsqueda local son siempre gratis, y puedes publicar un anuncio con prueba de trabajo en vez de la tarifa en sats.',
},
notFound: {
seoTitle: '404 - Página no encontrada',
seoDescription: 'La página que buscas no se pudo encontrar. Vuelve a la página de inicio para seguir navegando.',

View file

@ -13,6 +13,7 @@ import { RelatrSettings } from '@/components/RelatrSettings';
import { CVMHealthCheck } from '@/components/CVMHealthCheck';
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
import { GrantProCard } from '@/components/GrantProCard';
import { ProSupportScreen } from '@/components/ProSupportScreen';
import { AdminReviewCard } from '@/components/AdminReviewCard';
import { RegionBackfillCard } from '@/components/RegionBackfillCard';
import { NotificationSettingsCard } from '@/components/NotificationSettingsCard';
@ -34,7 +35,7 @@ import { cn } from '@/lib/utils';
import { NIP05_PROVIDERS } from '@/lib/nip05Providers';
import { SKATE_NIP05_DOMAIN } from '@/lib/skateNip05';
import { useTranslation } from '@/contexts/language';
import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff, Bell, Server } from 'lucide-react';
import { User, Radio, Wallet, Wrench, RotateCcw, Shield, ExternalLink, Languages, RefreshCw, AlertCircle, Database, Package, ChevronRight, ChevronLeft, Smartphone, Download, Zap, WifiOff, Bell, Server, Crown } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
@ -52,7 +53,8 @@ type Section =
| 'stash'
| 'notifications'
| 'coordinator'
| 'advanced';
| 'advanced'
| 'pro';
// ─── Back header ──────────────────────────────────────────────────────────────
@ -818,6 +820,14 @@ function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
iconBg="bg-pink-500/15"
onClick={() => onNavigate('appearance')}
/>
<MenuRow
icon={Crown}
label={t('settingsUi.proSupport.title')}
sublabel={t('settingsUi.proSupport.subtitle')}
iconColor="text-[#ccff00]"
iconBg="bg-[#ccff00]/15"
onClick={() => onNavigate('pro')}
/>
<MenuRow
icon={Bell}
label={t('settingsUi.settingsPage.notifications')}
@ -882,7 +892,7 @@ export default function SettingsPage() {
// stale bookmark to ?section=language) falls back to the menu rather than a
// blank screen with no way back.
const RENDERABLE_SECTIONS: Section[] = [
'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'notifications', 'coordinator', 'advanced',
'profile', 'security', 'nip05', 'mydata', 'relays', 'appearance', 'wallet', 'notifications', 'coordinator', 'advanced', 'pro',
];
const requestedSection = searchParams.get('section') as Section;
const initialSection =
@ -922,6 +932,7 @@ export default function SettingsPage() {
)}
{section === 'relays' && <RelaysScreen onBack={handleBack} />}
{section === 'appearance' && <AppearanceScreen onBack={handleBack} />}
{section === 'pro' && <ProSupportScreen onBack={handleBack} />}
{section === 'wallet' && <WalletScreen onBack={handleBack} />}
{section === 'notifications' && (
<div>