Files
zeekay 438b8644f3 rewards: /leaderboard and /bounty on the one points ledger
Every page that names points reads the cloud's rewards: the board
(GET /v1/rewards), the reader's standing, badges and history
(/v1/rewards/me), the listing (/v1/rewards/optin) and the rules table
(/v1/rewards/rules), snapshotted at build into
lib/data/rewards-rules.json and asked again when a page opens.  The
hard-coded RULES tiles, POINTS, the client-side score and
lib/hanzo/board.ts are gone; /beta and /referral draw the same Rules and
board and link to /leaderboard.

/leaderboard is the home: tabs by source and period, Your standing,
badges earned and locked, the rules, token milestones and levels, Link
GitHub through the existing per-person connection (the page completes
the grant GitHub returns), and Share your rank with a leaderboard card.
/bounty prints the contribution kinds and cap, three steps, the open
issues labelled bounty or good first issue in Hanzo's orgs (searched at
build with a token only), My contributions and the rules.  A star now
counts for the linked GitHub account, so Earn more offers Link GitHub.

The cards, nav, footer, llms.txt and sitemap carry both pages; /founder
no longer says points go nowhere.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-27 01:56:45 -07:00

173 lines
6.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useMemo, useState } from 'react'
import { ArrowRight } from 'lucide-react'
import { Text, View, XStack, YStack } from '@hanzo/ui'
import { Action, Chip, Title } from '@hanzo/ui/marketing'
import { BOOKING } from '@/components/home/nav-data'
import { useI18n } from '@hanzo/i18n/react'
import { useIam } from '@hanzo/iam/react'
import { track } from '@hanzo/app/lib/tags'
import { BETAS, opens } from '@hanzo/app/lib/betas'
import { scope } from '@hanzo/app/lib/session'
import { useTier } from '@hanzo/app/lib/tier'
import { Alpha } from './alpha'
import { Betas } from './betas'
import { Card, Quiet } from './card'
import { Rank } from './rank'
import { Share } from './share'
import { useStanding } from './standing'
import type { Program } from './program'
export interface BetaDashboardProps {
email: string
name?: string
program?: Program | null
paid?: boolean
checkoutUrl?: string
/** The live deal in words, from the quote: "50% off Pro for your first 3 months". */
offer?: string
onEditApplication?: () => void
}
/** The plan a member is on, as the header names it. */
function plan(raw: string, paid: boolean): string {
const r = raw.toLowerCase()
if (r.includes('agency')) return 'Agency'
if (r.includes('enterprise')) return 'Enterprise'
if (r.includes('team')) return 'Team'
if (r.includes('max') || r.includes('ultra')) return 'Max'
return r.includes('pro') || r.includes('dev') || paid ? 'Pro' : 'Free'
}
/**
* The member's beta, in four steps: share, the leaderboard, the betas, Alpha and
* a word to the team. What each reads is `useStanding`; what each says about
* scoring is said once, in the second.
*/
export function BetaDashboard({ email, name = '', program = 'beta', paid = false, checkoutUrl = '/pay/cart?coupon=50OFFBETA', offer, onEditApplication }: BetaDashboardProps) {
const { t } = useI18n()
const { isAuthenticated } = useIam()
const signed = Boolean(isAuthenticated && typeof window !== 'undefined' && scope().Authorization)
const { tier } = useTier(signed)
const s = useStanding(signed)
const [step, setStep] = useState<1 | 2 | 3 | 4>(1)
const handle = s.code ? `@${s.code}` : email ? `@${email.split('@')[0]}` : t('you')
const on = useMemo(() => BETAS.filter((b) => opens(b.id, s.flags, s.staff)).length, [s.flags, s.staff])
if (!signed) {
return (
<Card gap={20} maxW={680} mx="auto" data-slot="beta-account-gate">
<Title render="h1">{t('Create your account to continue.')}</Title>
<XStack gap={10} flexWrap="wrap">
<Action href={`/signup?next=${encodeURIComponent('/beta')}`} fill>
{t('Create account')}
<ArrowRight size={16} aria-hidden />
</Action>
<Action href={`/login?next=${encodeURIComponent('/beta')}`}>{t('Sign in')}</Action>
</XStack>
</Card>
)
}
const steps = [
{ n: 1, title: t('1. Share & Earn'), sub: t('{n} referred', { n: s.referred }) },
{ n: 2, title: t('2. Leaderboard'), sub: `${s.place > 0 ? `#${s.place}` : '—'} · ${t('{n} pts', { n: s.total })}` },
{ n: 3, title: t('3. Ecosystem Betas'), sub: t('{n} on', { n: on }) },
{ n: 4, title: t('4. Alpha & Feedback'), sub: s.asked.includes('alpha') ? t('Requested') : t('Apply') },
] as const
return (
<YStack data-motion="rise" gap={16} width="100%" maxW={960} mx="auto" data-slot="beta-dashboard">
<Card gap={20}>
<XStack items="flex-start" justify="space-between" flexWrap="wrap" gap={16}>
<YStack gap={4} flex={1} minW={0} flexBasis={280}>
<Title render="h1">{paid ? t('You’re in the Hanzo beta.') : t('Your beta application is in.')}</Title>
{email ? (
<Text fontSize="$3" color="var(--muted-foreground)" $platform-web={{ overflowWrap: 'anywhere' }}>
{email}
</Text>
) : null}
</YStack>
<XStack gap={8} flexWrap="wrap">
<Chip px={14} py={8}>
{t('{n} pts', { n: s.total })}
</Chip>
<Chip px={14} py={8}>
{s.place > 0 ? `#${s.place}` : '—'}
{s.of > 0 ? ` / ${s.of.toLocaleString()}` : ''}
</Chip>
<Chip px={14} py={8}>
{t('{plan} plan', { plan: plan(tier?.tier?.name || tier?.plan || '', paid) })}
</Chip>
</XStack>
</XStack>
<XStack items="center" gap={10} flexWrap="wrap">
{paid ? null : (
<Action href={checkoutUrl} fill>
{t('Continue with plan')}
<ArrowRight size={15} aria-hidden data-i18n-mirror />
</Action>
)}
{onEditApplication ? (
<Action render="button" type="button" onClick={onEditApplication}>
{t('Review application')}
</Action>
) : null}
{!paid && offer ? (
<Text fontSize="$2" color="var(--muted-foreground)">
{offer}
</Text>
) : null}
</XStack>
<View display="grid" gap={8} pt={20} borderTopWidth={1} borderColor="var(--border)" gridTemplateColumns="repeat(2, minmax(0, 1fr))" $sm={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}>
{steps.map((x) => (
<YStack
key={x.n}
render={
<button
type="button"
aria-pressed={step === x.n}
onClick={() => {
track('beta_dashboard_step_selected', { step: x.n })
setStep(x.n)
}}
/>
}
gap={2}
px={14}
py={10}
rounded={12}
borderWidth={1}
borderColor={step === x.n ? 'var(--foreground)' : 'var(--border)'}
bg={step === x.n ? 'var(--secondary)' : 'transparent'}
$platform-web={{ textAlign: 'start' }}
>
<Text fontSize="$3" fontWeight="600" color="var(--foreground)">
{x.title}
</Text>
<Text fontFamily="$mono" fontSize="$2" color="var(--muted-foreground)">
{x.sub}
</Text>
</YStack>
))}
</View>
</Card>
{step === 1 ? <Share link={s.link} code={s.code} program={program ?? 'beta'} rename={s.rename} asked={s.asked} /> : null}
{step === 2 ? <Rank s={s} handle={handle} /> : null}
{step === 3 ? <Betas flags={s.flags} staff={s.staff} asked={s.asked} request={s.request} /> : null}
{step === 4 ? <Alpha email={email} name={name} program={program} asked={s.asked} request={s.request} /> : null}
<XStack gap={20} flexWrap="wrap">
<Quiet href="/leaderboard">{t('The leaderboard')}</Quiet>
<Quiet href={BOOKING} target="_blank" rel="noopener noreferrer" onClick={() => track('beta_call_booked', { from: 'dashboard' })}>
{t('Book a call')}
</Quiet>
</XStack>
</YStack>
)
}