repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
The Living OS cockpit
stars
latest
clone command
git clone gitlawb://did:key:z6Mku78K...XywC/living-os-cockp...git clone gitlawb://did:key:z6Mku78K.../living-os-cockp...59751530feat: surface worker supervisor health in live work5h ago| #1 | 'use client'; |
| #2 | |
| #3 | import { useEffect, useMemo, useRef, useState } from 'react'; |
| #4 | import { sameOriginFetch } from '@/lib/client-api'; |
| #5 | import type { CSSProperties } from 'react'; |
| #6 | import { Edit3, RotateCcw, Save, X } from 'lucide-react'; |
| #7 | |
| #8 | type ProfileBadge = { |
| #9 | label: string; |
| #10 | kind: string; |
| #11 | }; |
| #12 | |
| #13 | type ActiveCampaign = { |
| #14 | id: string; |
| #15 | title: string; |
| #16 | }; |
| #17 | |
| #18 | type TierOption = { |
| #19 | id: string; |
| #20 | label: string; |
| #21 | detail?: string; |
| #22 | }; |
| #23 | |
| #24 | export type SovereignProfile = { |
| #25 | displayName: string; |
| #26 | editable?: boolean; |
| #27 | portraitLabel?: string; |
| #28 | portraitUrl?: string | null; |
| #29 | tierOptions?: TierOption[]; |
| #30 | paidTier: { |
| #31 | id?: string; |
| #32 | label: string; |
| #33 | detail?: string; |
| #34 | }; |
| #35 | pma?: { |
| #36 | feeUsd: number; |
| #37 | status: 'paid' | 'pending'; |
| #38 | renewalLabel?: string; |
| #39 | }; |
| #40 | cooperativePool?: { |
| #41 | phase: string; |
| #42 | poolPercent: number; |
| #43 | operatorPercent: number; |
| #44 | donationPercent: number; |
| #45 | memberShareLabel: string; |
| #46 | memberSharePercent: number; |
| #47 | }; |
| #48 | sovereignStatus: { |
| #49 | id: string; |
| #50 | label: string; |
| #51 | nextMilestone: string; |
| #52 | }; |
| #53 | xp: { |
| #54 | current: number; |
| #55 | next: number; |
| #56 | }; |
| #57 | trust: { |
| #58 | name: string; |
| #59 | id?: string | null; |
| #60 | role?: string | null; |
| #61 | uccFiling?: string | null; |
| #62 | filingState?: string | null; |
| #63 | governingLaw?: string | null; |
| #64 | primaryColor: string; |
| #65 | secondaryColor: string; |
| #66 | sealUrl?: string | null; |
| #67 | }; |
| #68 | stats: { |
| #69 | posts: number; |
| #70 | interactions: number; |
| #71 | activeCampaigns: number; |
| #72 | openThreads: number; |
| #73 | }; |
| #74 | activeCampaigns: ActiveCampaign[]; |
| #75 | badges: ProfileBadge[]; |
| #76 | }; |
| #77 | |
| #78 | type ProfileDraft = { |
| #79 | displayName: string; |
| #80 | portraitLabel: string; |
| #81 | trustName: string; |
| #82 | trustId: string; |
| #83 | trustRole: string; |
| #84 | uccFiling: string; |
| #85 | filingState: string; |
| #86 | governingLaw: string; |
| #87 | tierId: string; |
| #88 | pmaStatus: 'paid' | 'pending'; |
| #89 | pmaFeeUsd: string; |
| #90 | poolPercent: string; |
| #91 | operatorPercent: string; |
| #92 | donationPercent: string; |
| #93 | memberSharePercent: string; |
| #94 | }; |
| #95 | |
| #96 | const FALLBACK_TIER_OPTIONS: TierOption[] = [ |
| #97 | { id: 'founding_court', label: 'Founding', detail: 'Sovereign Court' }, |
| #98 | { id: 'sovereign_court', label: 'Sovereign Court', detail: 'Premium consultation tier' }, |
| #99 | { id: 'dragon', label: 'Dragon', detail: 'Expanded case capacity' }, |
| #100 | { id: 'sovereign', label: 'Sovereign', detail: 'Private member tier' }, |
| #101 | { id: 'free', label: 'Free', detail: 'PMA member entry' }, |
| #102 | ]; |
| #103 | |
| #104 | function initials(name?: string) { |
| #105 | return (name ?? 'Member') |
| #106 | .split(/\s+/) |
| #107 | .filter(Boolean) |
| #108 | .slice(0, 2) |
| #109 | .map(part => part[0]?.toUpperCase()) |
| #110 | .join(''); |
| #111 | } |
| #112 | |
| #113 | function xpLabel(current: number, next: number) { |
| #114 | if (next <= current) return `${current.toLocaleString()} XP`; |
| #115 | return `${current.toLocaleString()} / ${next.toLocaleString()} XP`; |
| #116 | } |
| #117 | |
| #118 | function money(value?: number) { |
| #119 | return `$${Number(value ?? 0).toFixed(2)}`; |
| #120 | } |
| #121 | |
| #122 | function percent(value?: number) { |
| #123 | return `${Number(value ?? 0).toFixed(2).replace(/\.00$/, '')}%`; |
| #124 | } |
| #125 | |
| #126 | function draftFromProfile(profile: SovereignProfile): ProfileDraft { |
| #127 | const pool = profile.cooperativePool; |
| #128 | return { |
| #129 | displayName: profile.displayName ?? '', |
| #130 | portraitLabel: profile.portraitLabel || initials(profile.displayName), |
| #131 | trustName: profile.trust.name ?? '', |
| #132 | trustId: profile.trust.id ?? '', |
| #133 | trustRole: profile.trust.role ?? '', |
| #134 | uccFiling: profile.trust.uccFiling ?? '', |
| #135 | filingState: profile.trust.filingState ?? '', |
| #136 | governingLaw: profile.trust.governingLaw ?? '', |
| #137 | tierId: profile.paidTier.id ?? profile.tierOptions?.[0]?.id ?? 'founding_court', |
| #138 | pmaStatus: profile.pma?.status ?? 'pending', |
| #139 | pmaFeeUsd: String(profile.pma?.feeUsd ?? 33.33), |
| #140 | poolPercent: String(pool?.poolPercent ?? 91), |
| #141 | operatorPercent: String(pool?.operatorPercent ?? 6), |
| #142 | donationPercent: String(pool?.donationPercent ?? 3), |
| #143 | memberSharePercent: String(pool?.memberSharePercent ?? 0), |
| #144 | }; |
| #145 | } |
| #146 | |
| #147 | function numberOr(value: string, fallback: number) { |
| #148 | const numeric = Number(value); |
| #149 | return Number.isFinite(numeric) ? numeric : fallback; |
| #150 | } |
| #151 | |
| #152 | const STATUS_THRESHOLDS = [ |
| #153 | { id: 'initiate', label: 'Initiate', xp: 0 }, |
| #154 | { id: 'awakening', label: 'Awakening', xp: 250 }, |
| #155 | { id: 'living', label: 'Living', xp: 750 }, |
| #156 | { id: 'secured', label: 'Secured', xp: 1500 }, |
| #157 | { id: 'executor', label: 'Executor', xp: 3000 }, |
| #158 | { id: 'dragon', label: 'Dragon of Law', xp: 5000 }, |
| #159 | ]; |
| #160 | |
| #161 | function statusIndexFromXp(xp: number) { |
| #162 | return STATUS_THRESHOLDS.reduce((active, threshold, index) => (xp >= threshold.xp ? index : active), 0); |
| #163 | } |
| #164 | |
| #165 | function statusIndexFromId(statusId?: string) { |
| #166 | const normalized = (statusId ?? '').replace(/_/g, '-').toLowerCase(); |
| #167 | const index = STATUS_THRESHOLDS.findIndex(threshold => { |
| #168 | if (threshold.id === normalized) return true; |
| #169 | return threshold.label.toLowerCase().replace(/\s+/g, '-') === normalized; |
| #170 | }); |
| #171 | return Math.max(0, index); |
| #172 | } |
| #173 | |
| #174 | export default function SovereignProfileCard({ profile }: { profile?: SovereignProfile }) { |
| #175 | const [currentProfile, setCurrentProfile] = useState<SovereignProfile | undefined>(profile); |
| #176 | const [editing, setEditing] = useState(false); |
| #177 | const [draft, setDraft] = useState<ProfileDraft | null>(profile ? draftFromProfile(profile) : null); |
| #178 | const [saving, setSaving] = useState(false); |
| #179 | const [error, setError] = useState(''); |
| #180 | |
| #181 | useEffect(() => { |
| #182 | if (!profile) return; |
| #183 | setCurrentProfile(profile); |
| #184 | if (!editing) setDraft(draftFromProfile(profile)); |
| #185 | }, [profile, editing]); |
| #186 | |
| #187 | const currentXp = Math.max(0, currentProfile?.xp.current ?? 0); |
| #188 | const statusId = currentProfile?.sovereignStatus.id; |
| #189 | const statusIndex = useMemo( |
| #190 | () => Math.max(statusIndexFromXp(currentXp), statusIndexFromId(statusId)), |
| #191 | [currentXp, statusId] |
| #192 | ); |
| #193 | const activeThreshold = STATUS_THRESHOLDS[statusIndex] ?? STATUS_THRESHOLDS[0]; |
| #194 | const nextThreshold = STATUS_THRESHOLDS[Math.min(STATUS_THRESHOLDS.length - 1, statusIndex + 1)]; |
| #195 | const targetXp = statusIndex >= STATUS_THRESHOLDS.length - 1 ? activeThreshold.xp : nextThreshold.xp; |
| #196 | const segmentSize = Math.max(1, targetXp - activeThreshold.xp); |
| #197 | const progress = statusIndex >= STATUS_THRESHOLDS.length - 1 |
| #198 | ? 100 |
| #199 | : Math.max(0, Math.min(100, Math.round(((currentXp - activeThreshold.xp) / segmentSize) * 100))); |
| #200 | const [thresholdCrossed, setThresholdCrossed] = useState(false); |
| #201 | const previousStatusIndex = useRef(statusIndex); |
| #202 | |
| #203 | useEffect(() => { |
| #204 | if (statusIndex > previousStatusIndex.current) { |
| #205 | setThresholdCrossed(true); |
| #206 | const timeout = window.setTimeout(() => setThresholdCrossed(false), 1400); |
| #207 | previousStatusIndex.current = statusIndex; |
| #208 | return () => window.clearTimeout(timeout); |
| #209 | } |
| #210 | previousStatusIndex.current = statusIndex; |
| #211 | }, [statusIndex]); |
| #212 | |
| #213 | if (!currentProfile) { |
| #214 | return ( |
| #215 | <section className="sovereign-profile-shell card p-4 lg:col-span-4"> |
| #216 | <div className="profile-skeleton h-64 rounded" /> |
| #217 | </section> |
| #218 | ); |
| #219 | } |
| #220 | |
| #221 | const tierOptions = currentProfile.tierOptions?.length ? currentProfile.tierOptions : FALLBACK_TIER_OPTIONS; |
| #222 | const style = { |
| #223 | '--trust-primary': currentProfile.trust.primaryColor, |
| #224 | '--trust-secondary': currentProfile.trust.secondaryColor, |
| #225 | } as CSSProperties; |
| #226 | |
| #227 | const updateDraft = (key: keyof ProfileDraft, value: string) => { |
| #228 | setDraft(previous => previous ? { ...previous, [key]: value } : previous); |
| #229 | }; |
| #230 | |
| #231 | const startEditing = () => { |
| #232 | setDraft(draftFromProfile(currentProfile)); |
| #233 | setError(''); |
| #234 | setEditing(true); |
| #235 | }; |
| #236 | |
| #237 | const saveProfile = async () => { |
| #238 | if (!draft) return; |
| #239 | const tier = tierOptions.find(option => option.id === draft.tierId) ?? tierOptions[0]; |
| #240 | setSaving(true); |
| #241 | setError(''); |
| #242 | try { |
| #243 | const response = await sameOriginFetch('/api/profile', { |
| #244 | method: 'PUT', |
| #245 | headers: { 'Content-Type': 'application/json' }, |
| #246 | body: JSON.stringify({ |
| #247 | displayName: draft.displayName, |
| #248 | portraitLabel: draft.portraitLabel, |
| #249 | paidTier: tier, |
| #250 | pma: { |
| #251 | status: draft.pmaStatus, |
| #252 | feeUsd: numberOr(draft.pmaFeeUsd, 33.33), |
| #253 | renewalLabel: currentProfile.pma?.renewalLabel ?? 'Annual PMA membership', |
| #254 | }, |
| #255 | cooperativePool: { |
| #256 | phase: currentProfile.cooperativePool?.phase ?? 'Ceiling Split', |
| #257 | poolPercent: numberOr(draft.poolPercent, 91), |
| #258 | operatorPercent: numberOr(draft.operatorPercent, 6), |
| #259 | donationPercent: numberOr(draft.donationPercent, 3), |
| #260 | memberShareLabel: currentProfile.cooperativePool?.memberShareLabel ?? 'Member cooperative pool', |
| #261 | memberSharePercent: numberOr(draft.memberSharePercent, 0), |
| #262 | }, |
| #263 | trust: { |
| #264 | name: draft.trustName, |
| #265 | id: draft.trustId || null, |
| #266 | role: draft.trustRole || null, |
| #267 | uccFiling: draft.uccFiling || null, |
| #268 | filingState: draft.filingState || null, |
| #269 | governingLaw: draft.governingLaw || null, |
| #270 | primaryColor: currentProfile.trust.primaryColor, |
| #271 | secondaryColor: currentProfile.trust.secondaryColor, |
| #272 | }, |
| #273 | }), |
| #274 | }); |
| #275 | const next = await response.json(); |
| #276 | if (!response.ok) throw new Error(next?.error ?? `Profile save failed (${response.status})`); |
| #277 | setCurrentProfile(next); |
| #278 | setDraft(draftFromProfile(next)); |
| #279 | setEditing(false); |
| #280 | } catch (err: any) { |
| #281 | setError(err?.message ?? 'Profile save failed'); |
| #282 | } finally { |
| #283 | setSaving(false); |
| #284 | } |
| #285 | }; |
| #286 | |
| #287 | const uploadPortrait = async (file?: File) => { |
| #288 | if (!file) return; |
| #289 | setSaving(true); |
| #290 | setError(''); |
| #291 | try { |
| #292 | const body = new FormData(); |
| #293 | body.append('file', file); |
| #294 | const response = await sameOriginFetch('/api/profile/photo', { method: 'POST', body }); |
| #295 | const next = await response.json(); |
| #296 | if (!response.ok) throw new Error(next?.error ?? `Portrait upload failed (${response.status})`); |
| #297 | setCurrentProfile(previous => previous ? { ...previous, portraitUrl: next.portraitUrl } : previous); |
| #298 | } catch (err: any) { |
| #299 | setError(err?.message ?? 'Portrait upload failed'); |
| #300 | } finally { |
| #301 | setSaving(false); |
| #302 | } |
| #303 | }; |
| #304 | |
| #305 | const deletePortrait = async () => { |
| #306 | setSaving(true); |
| #307 | setError(''); |
| #308 | try { |
| #309 | const response = await sameOriginFetch('/api/profile/photo', { method: 'DELETE' }); |
| #310 | const next = await response.json(); |
| #311 | if (!response.ok) throw new Error(next?.error ?? `Portrait delete failed (${response.status})`); |
| #312 | setCurrentProfile(previous => previous ? { ...previous, portraitUrl: null } : previous); |
| #313 | } catch (err: any) { |
| #314 | setError(err?.message ?? 'Portrait delete failed'); |
| #315 | } finally { |
| #316 | setSaving(false); |
| #317 | } |
| #318 | }; |
| #319 | |
| #320 | const resetProfile = async () => { |
| #321 | setSaving(true); |
| #322 | setError(''); |
| #323 | try { |
| #324 | const response = await sameOriginFetch('/api/profile', { method: 'DELETE' }); |
| #325 | const next = await response.json(); |
| #326 | if (!response.ok) throw new Error(next?.error ?? `Profile reset failed (${response.status})`); |
| #327 | setCurrentProfile(next); |
| #328 | setDraft(draftFromProfile(next)); |
| #329 | setEditing(false); |
| #330 | } catch (err: any) { |
| #331 | setError(err?.message ?? 'Profile reset failed'); |
| #332 | } finally { |
| #333 | setSaving(false); |
| #334 | } |
| #335 | }; |
| #336 | |
| #337 | const pool = currentProfile.cooperativePool; |
| #338 | const pma = currentProfile.pma; |
| #339 | |
| #340 | return ( |
| #341 | <section className="sovereign-profile-shell card p-4 lg:col-span-4" style={style}> |
| #342 | <div className="trust-frame"> |
| #343 | <div className="profile-topline"> |
| #344 | <div className="tier-stack"> |
| #345 | <span className="tier-badge">{currentProfile.paidTier.label}</span> |
| #346 | {currentProfile.paidTier.detail && <span className="tier-detail">{currentProfile.paidTier.detail}</span>} |
| #347 | </div> |
| #348 | <div className="profile-actions"> |
| #349 | <span className={`status-badge status-${currentProfile.sovereignStatus.id}`}> |
| #350 | {currentProfile.sovereignStatus.label} |
| #351 | </span> |
| #352 | {currentProfile.editable && !editing && ( |
| #353 | <button type="button" className="profile-icon-button" onClick={startEditing} title="Edit profile"> |
| #354 | <Edit3 size={14} /> |
| #355 | </button> |
| #356 | )} |
| #357 | </div> |
| #358 | </div> |
| #359 | |
| #360 | <div className="profile-portrait-row"> |
| #361 | <div className="profile-portrait"> |
| #362 | {currentProfile.portraitUrl ? ( |
| #363 | <img className="profile-photo" src={currentProfile.portraitUrl} alt={`${currentProfile.displayName} portrait`} /> |
| #364 | ) : ( |
| #365 | <div className="profile-initials">{currentProfile.portraitLabel || initials(currentProfile.displayName)}</div> |
| #366 | )} |
| #367 | <div className="trust-seal" aria-label="Trust seal"> |
| #368 | {currentProfile.trust.sealUrl ? ( |
| #369 | <img src={currentProfile.trust.sealUrl} alt={`${currentProfile.trust.name} seal`} /> |
| #370 | ) : ( |
| #371 | <span>{initials(currentProfile.trust.name)}</span> |
| #372 | )} |
| #373 | </div> |
| #374 | </div> |
| #375 | <div className="profile-identity"> |
| #376 | <h2>{currentProfile.displayName}</h2> |
| #377 | <p className="trust-name">{currentProfile.trust.name}</p> |
| #378 | <p className="trust-meta"> |
| #379 | {currentProfile.trust.id ? currentProfile.trust.id : 'Trust setup pending'} |
| #380 | {currentProfile.trust.uccFiling ? ` · UCC ${currentProfile.trust.uccFiling}` : ''} |
| #381 | </p> |
| #382 | </div> |
| #383 | </div> |
| #384 | |
| #385 | {editing && draft && ( |
| #386 | <div className="profile-edit-panel"> |
| #387 | <div className="profile-edit-grid"> |
| #388 | <label> |
| #389 | <span>Display name</span> |
| #390 | <input value={draft.displayName} onChange={event => updateDraft('displayName', event.target.value)} /> |
| #391 | </label> |
| #392 | <label> |
| #393 | <span>Portrait label</span> |
| #394 | <input value={draft.portraitLabel} maxLength={8} onChange={event => updateDraft('portraitLabel', event.target.value.toUpperCase())} /> |
| #395 | </label> |
| #396 | <label className="wide"> |
| #397 | <span>Portrait photo</span> |
| #398 | <input type="file" accept="image/*" onChange={event => uploadPortrait(event.target.files?.[0])} /> |
| #399 | </label> |
| #400 | <label className="wide"> |
| #401 | <span>Trust name</span> |
| #402 | <input value={draft.trustName} onChange={event => updateDraft('trustName', event.target.value)} /> |
| #403 | </label> |
| #404 | <label> |
| #405 | <span>Trust ID</span> |
| #406 | <input value={draft.trustId} onChange={event => updateDraft('trustId', event.target.value)} /> |
| #407 | </label> |
| #408 | <label> |
| #409 | <span>Trustee role</span> |
| #410 | <input value={draft.trustRole} onChange={event => updateDraft('trustRole', event.target.value)} /> |
| #411 | </label> |
| #412 | <label> |
| #413 | <span>UCC filing</span> |
| #414 | <input value={draft.uccFiling} onChange={event => updateDraft('uccFiling', event.target.value)} /> |
| #415 | </label> |
| #416 | <label> |
| #417 | <span>Filing state</span> |
| #418 | <input value={draft.filingState} onChange={event => updateDraft('filingState', event.target.value)} /> |
| #419 | </label> |
| #420 | <label className="wide"> |
| #421 | <span>Governing law</span> |
| #422 | <input value={draft.governingLaw} onChange={event => updateDraft('governingLaw', event.target.value)} /> |
| #423 | </label> |
| #424 | <label> |
| #425 | <span>Member tier</span> |
| #426 | <select value={draft.tierId} onChange={event => updateDraft('tierId', event.target.value)}> |
| #427 | {tierOptions.map(option => ( |
| #428 | <option key={option.id} value={option.id}>{option.label}</option> |
| #429 | ))} |
| #430 | </select> |
| #431 | </label> |
| #432 | <label> |
| #433 | <span>PMA fee</span> |
| #434 | <input value={draft.pmaFeeUsd} onChange={event => updateDraft('pmaFeeUsd', event.target.value)} /> |
| #435 | </label> |
| #436 | <label> |
| #437 | <span>PMA status</span> |
| #438 | <select value={draft.pmaStatus} onChange={event => updateDraft('pmaStatus', event.target.value)}> |
| #439 | <option value="pending">Pending</option> |
| #440 | <option value="paid">Paid</option> |
| #441 | </select> |
| #442 | </label> |
| #443 | <label> |
| #444 | <span>Member pool share</span> |
| #445 | <input value={draft.memberSharePercent} onChange={event => updateDraft('memberSharePercent', event.target.value)} /> |
| #446 | </label> |
| #447 | <label> |
| #448 | <span>Pool split</span> |
| #449 | <input value={draft.poolPercent} onChange={event => updateDraft('poolPercent', event.target.value)} /> |
| #450 | </label> |
| #451 | <label> |
| #452 | <span>Operator split</span> |
| #453 | <input value={draft.operatorPercent} onChange={event => updateDraft('operatorPercent', event.target.value)} /> |
| #454 | </label> |
| #455 | <label> |
| #456 | <span>Donation split</span> |
| #457 | <input value={draft.donationPercent} onChange={event => updateDraft('donationPercent', event.target.value)} /> |
| #458 | </label> |
| #459 | </div> |
| #460 | {error && <div className="profile-edit-error">{error}</div>} |
| #461 | <div className="profile-edit-actions"> |
| #462 | <button type="button" className="profile-text-button" onClick={saveProfile} disabled={saving}> |
| #463 | <Save size={14} /> |
| #464 | Save |
| #465 | </button> |
| #466 | <button type="button" className="profile-text-button ghost" onClick={() => setEditing(false)} disabled={saving}> |
| #467 | <X size={14} /> |
| #468 | Cancel |
| #469 | </button> |
| #470 | <button type="button" className="profile-text-button ghost" onClick={resetProfile} disabled={saving}> |
| #471 | <RotateCcw size={14} /> |
| #472 | Reset |
| #473 | </button> |
| #474 | {currentProfile.portraitUrl && ( |
| #475 | <button type="button" className="profile-text-button ghost" onClick={deletePortrait} disabled={saving}> |
| #476 | <X size={14} /> |
| #477 | Photo |
| #478 | </button> |
| #479 | )} |
| #480 | </div> |
| #481 | </div> |
| #482 | )} |
| #483 | |
| #484 | <div |
| #485 | className={`status-progression ${thresholdCrossed ? 'threshold-crossed' : ''}`} |
| #486 | aria-label={`Sovereign status progression: ${currentXp.toLocaleString()} XP toward ${targetXp.toLocaleString()} XP`} |
| #487 | > |
| #488 | <div className="status-progression-top"> |
| #489 | <span>XP threshold</span> |
| #490 | <strong> |
| #491 | {activeThreshold.label} |
| #492 | {statusIndex < STATUS_THRESHOLDS.length - 1 ? ` to ${nextThreshold.label}` : ' complete'} |
| #493 | </strong> |
| #494 | </div> |
| #495 | |
| #496 | <div className="sovereign-ladder"> |
| #497 | {STATUS_THRESHOLDS.map((threshold, index) => ( |
| #498 | <div |
| #499 | key={threshold.id} |
| #500 | className={`ladder-step ${index < statusIndex ? 'complete' : ''} ${index === statusIndex ? 'active' : ''}`} |
| #501 | style={{ '--step-index': index } as CSSProperties} |
| #502 | > |
| #503 | <span className="ladder-dot" /> |
| #504 | <small>{threshold.label}</small> |
| #505 | <em>{threshold.xp.toLocaleString()} XP</em> |
| #506 | </div> |
| #507 | ))} |
| #508 | </div> |
| #509 | |
| #510 | <div className="xp-thread xp-thread-advanced" aria-label={xpLabel(currentXp, targetXp)}> |
| #511 | <div className="xp-thread-fill" style={{ width: `${progress}%` }} /> |
| #512 | <span>{xpLabel(currentXp, targetXp)}</span> |
| #513 | </div> |
| #514 | |
| #515 | <div className="xp-next-target"> |
| #516 | <span>{statusIndex < STATUS_THRESHOLDS.length - 1 ? 'Next threshold target' : 'Highest threshold held'}</span> |
| #517 | <strong>{targetXp.toLocaleString()} XP</strong> |
| #518 | </div> |
| #519 | </div> |
| #520 | |
| #521 | <div className="profile-stat-grid"> |
| #522 | <div> |
| #523 | <span>{currentProfile.stats.posts}</span> |
| #524 | <label>Posts</label> |
| #525 | </div> |
| #526 | <div> |
| #527 | <span>{currentProfile.stats.interactions}</span> |
| #528 | <label>Interactions</label> |
| #529 | </div> |
| #530 | <div> |
| #531 | <span>{currentProfile.stats.activeCampaigns}</span> |
| #532 | <label>Campaigns</label> |
| #533 | </div> |
| #534 | <div> |
| #535 | <span>{currentProfile.stats.openThreads}</span> |
| #536 | <label>Threads</label> |
| #537 | </div> |
| #538 | </div> |
| #539 | |
| #540 | <div className="profile-section"> |
| #541 | <div className="profile-section-title">Membership</div> |
| #542 | <div className="membership-grid"> |
| #543 | <div> |
| #544 | <span>PMA Fee</span> |
| #545 | <strong>{money(pma?.feeUsd ?? 33.33)}</strong> |
| #546 | <small className={`pma-${pma?.status ?? 'pending'}`}>{pma?.status ?? 'pending'}</small> |
| #547 | </div> |
| #548 | <div> |
| #549 | <span>Upgrade Path</span> |
| #550 | <strong>{tierOptions.find(option => option.id !== currentProfile.paidTier.id)?.label ?? 'Current'}</strong> |
| #551 | <small>{currentProfile.paidTier.detail ?? 'Member tier'}</small> |
| #552 | </div> |
| #553 | </div> |
| #554 | </div> |
| #555 | |
| #556 | <div className="profile-section"> |
| #557 | <div className="profile-section-title">Cooperative Pool</div> |
| #558 | <div className="pool-panel" aria-label="Cooperative pool split"> |
| #559 | <div className="pool-bar"> |
| #560 | <span style={{ width: `${pool?.poolPercent ?? 91}%` }} /> |
| #561 | <span style={{ width: `${pool?.operatorPercent ?? 6}%` }} /> |
| #562 | <span style={{ width: `${pool?.donationPercent ?? 3}%` }} /> |
| #563 | </div> |
| #564 | <div className="pool-legend"> |
| #565 | <span>Pool {percent(pool?.poolPercent ?? 91)}</span> |
| #566 | <span>Operator {percent(pool?.operatorPercent ?? 6)}</span> |
| #567 | <span>Donation {percent(pool?.donationPercent ?? 3)}</span> |
| #568 | </div> |
| #569 | <div className="pool-share"> |
| #570 | <strong>{percent(pool?.memberSharePercent ?? 0)}</strong> |
| #571 | <span>{pool?.memberShareLabel ?? 'Member cooperative pool'}</span> |
| #572 | </div> |
| #573 | </div> |
| #574 | </div> |
| #575 | |
| #576 | <div className="profile-section"> |
| #577 | <div className="profile-section-title">Badges</div> |
| #578 | <div className="badge-gallery"> |
| #579 | {currentProfile.badges.map(badge => ( |
| #580 | <span key={`${badge.kind}-${badge.label}`} className={`earned-badge earned-${badge.kind}`}> |
| #581 | {badge.label} |
| #582 | </span> |
| #583 | ))} |
| #584 | </div> |
| #585 | </div> |
| #586 | |
| #587 | <div className="profile-section"> |
| #588 | <div className="profile-section-title">Active Campaigns</div> |
| #589 | <div className="campaign-list"> |
| #590 | {currentProfile.activeCampaigns.length > 0 ? ( |
| #591 | currentProfile.activeCampaigns.slice(0, 4).map(campaign => ( |
| #592 | <div key={campaign.id} className="campaign-row"> |
| #593 | <span>{campaign.title}</span> |
| #594 | </div> |
| #595 | )) |
| #596 | ) : ( |
| #597 | <div className="campaign-row muted"> |
| #598 | <span>No active campaigns filed yet</span> |
| #599 | </div> |
| #600 | )} |
| #601 | </div> |
| #602 | </div> |
| #603 | |
| #604 | <div className="next-milestone"> |
| #605 | <span>Next milestone</span> |
| #606 | <strong>{currentProfile.sovereignStatus.nextMilestone}</strong> |
| #607 | </div> |
| #608 | </div> |
| #609 | </section> |
| #610 | ); |
| #611 | } |
| #612 |