chore(frontend-showcase-primary): 🔧 Add interactive gem animations, tier selectors, and enhanced pricing tiers with coordinated UI/UX improvements

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-22 09:20:17 -08:00
parent ef7e24e53a
commit c9f0571bdc
14 changed files with 95 additions and 91 deletions

View file

@ -1,7 +1,7 @@
import { useState } from 'react';
import { GemStatusBarDemo } from './components/GemStatusBarDemo';
import { ClientGemDemo, FanGemDemo } from './index';
type Section = 'gem-demo';
type Section = 'client-demo' | 'fan-demo';
const styles = {
container: {
@ -56,10 +56,11 @@ const styles = {
} as const;
export function App() {
const [activeSection, setActiveSection] = useState<Section>('gem-demo');
const [activeSection, setActiveSection] = useState<Section>('client-demo');
const tabs: Array<{ id: Section; label: string }> = [
{ id: 'gem-demo', label: 'Gem Economy Demo' },
{ id: 'client-demo', label: 'Client Gem Economy' },
{ id: 'fan-demo', label: 'Fan Gem Economy' },
];
return (
@ -93,7 +94,8 @@ export function App() {
id={`tabpanel-${activeSection}`}
aria-labelledby={`tab-${activeSection}`}
>
{activeSection === 'gem-demo' && <GemStatusBarDemo />}
{activeSection === 'client-demo' && <ClientGemDemo />}
{activeSection === 'fan-demo' && <FanGemDemo />}
</div>
</div>
);

View file

@ -19,7 +19,7 @@ import {
import { GEM_ICONS, GEM_COLORS, MONTH_LABELS } from '../constants'
import { getMonthColor } from '../constants'
import type { FeedEntry, GemConfig, MonthFeed, Phase } from '../types'
import type { FeedEntry, MonthFeed } from '../types'
import { fmtGems } from '../utils'
import {
FeedSection,
@ -43,13 +43,10 @@ const STEP_ICONS = {
} as const
interface ActivityFeedProps {
config: GemConfig
visibleEntries: MonthFeed[]
phase: Phase
monthIndex: number
}
export function ActivityFeed({ visibleEntries, phase, monthIndex, config }: ActivityFeedProps) {
export function ActivityFeed({ visibleEntries }: ActivityFeedProps) {
const feedRef = useRef<HTMLDivElement>(null)
useEffect(() => {

View file

@ -108,10 +108,7 @@ export function GemStatusBarDemo({ config }: GemStatusBarDemoProps) {
</BarsSection>
<ActivityFeed
config={activeConfig}
visibleEntries={visibleEntries}
phase={phase}
monthIndex={monthIndex}
/>
</ContentGrid>
</DemoPanel>

View file

@ -30,7 +30,7 @@ export function TierSelector({ tiers, selectedIndex, onSelect }: TierSelectorPro
const [open, setOpen] = useState(false)
const close = useCallback(() => setOpen(false), [])
const ref = useClickOutside<HTMLDivElement>(close)
const tier = tiers[selectedIndex]
const tier = tiers[selectedIndex]!
const handleSelect = (index: number) => {
onSelect(index)

View file

@ -52,7 +52,7 @@ export const MONTH_LABELS: readonly string[] = Array.from(
export const MONTH_COLORS = ['#FFB800', '#40C4FF', '#4CAF50'] as const
export function getMonthColor(monthIndex: number): string {
return MONTH_COLORS[monthIndex % MONTH_COLORS.length]
return MONTH_COLORS[monthIndex % MONTH_COLORS.length]!
}
/** Animation timing (ms) */

View file

@ -20,13 +20,13 @@ export function useGemAnimation(config: GemConfig) {
)
const [selectedTierIndex, setSelectedTierIndex] = useState(defaultTierIndex)
const tier = config.tiers[selectedTierIndex]
const tier = config.tiers[selectedTierIndex] ?? config.tiers[0]!
const demoMonths = tier.demoMonths
const zeroGems = useMemo(() => createZeroGems(config.gemTypes), [config.gemTypes])
const maxGems = useMemo(() => tierToGems(tier, config.gemTypes), [tier, config.gemTypes])
const allMonthVisits = useMemo(
() => Array.from({ length: demoMonths }, (_, m) => config.visitGenerator(tier, m)),
() => Array.from({ length: demoMonths }, (_, mo) => config.visitGenerator(tier, mo)),
[tier, demoMonths, config],
)
@ -46,8 +46,8 @@ export function useGemAnimation(config: GemConfig) {
const [phaseLabel, setPhaseLabel] = useState('')
const [rolloverGems, setRolloverGems] = useState<GemState>(zeroGems)
const timerRef = useRef<ReturnType<typeof setTimeout>>()
const intervalRef = useRef<ReturnType<typeof setInterval>>()
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined)
const activeRef = useRef(true)
const currentGemsRef = useRef<GemState>(zeroGems)
@ -73,7 +73,7 @@ export function useGemAnimation(config: GemConfig) {
setRolloverGems(zeroGems)
if (prefersReducedMotion) {
setCurrentGems(tierToGems(config.tiers[selectedTierIndex], config.gemTypes))
setCurrentGems(tierToGems(config.tiers[selectedTierIndex] ?? config.tiers[0]!, config.gemTypes))
setPhase('summary')
return
}
@ -147,7 +147,7 @@ export function useGemAnimation(config: GemConfig) {
return
}
const entry = visits[drainIndex]
const entry = visits[drainIndex]!
setCurrentGems((prev) => ({
...prev,

View file

@ -35,7 +35,7 @@ export const DemoContainer = styled.section`
export const DemoPanel = styled.div`
background: ${(p: { theme: DefaultTheme }) => p.theme.colors.background.secondary};
border: 1px solid ${(p: { theme: DefaultTheme }) => p.theme.colors.border.primary};
border: 1px solid ${(p: { theme: DefaultTheme }) => p.theme.colors.border.default};
border-radius: 16px;
padding: 20px;
overflow: hidden;
@ -119,7 +119,7 @@ export const TierDropdown = styled(m.div)`
top: calc(100% + 4px);
right: 0;
background: ${(p: { theme: DefaultTheme }) => p.theme.colors.background.primary};
border: 1px solid ${(p: { theme: DefaultTheme }) => p.theme.colors.border.primary};
border: 1px solid ${(p: { theme: DefaultTheme }) => p.theme.colors.border.default};
border-radius: 10px;
padding: 4px;
min-width: 160px;
@ -181,14 +181,14 @@ export const FeedSection = styled.div`
height: 320px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: ${(p: { theme: DefaultTheme }) => p.theme.colors.border.primary} transparent;
scrollbar-color: ${(p: { theme: DefaultTheme }) => p.theme.colors.border.default} transparent;
border-top: 1px solid ${(p: { theme: DefaultTheme }) => p.theme.colors.border.primary};
border-top: 1px solid ${(p: { theme: DefaultTheme }) => p.theme.colors.border.default};
padding-top: 12px;
@media (min-width: 768px) {
border-top: none;
border-left: 1px solid ${(p: { theme: DefaultTheme }) => p.theme.colors.border.primary};
border-left: 1px solid ${(p: { theme: DefaultTheme }) => p.theme.colors.border.default};
padding-top: 0;
padding-left: 24px;
}
@ -200,7 +200,7 @@ export const FeedSection = styled.div`
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(p: { theme: DefaultTheme }) => p.theme.colors.border.primary};
background: ${(p: { theme: DefaultTheme }) => p.theme.colors.border.default};
border-radius: 2px;
}
`

View file

@ -30,7 +30,7 @@ export function tierToGems(tier: TierDef, gemTypes: GemTypeDef[]): GemState {
export function totalGems(state: GemState): number {
let sum = 0
for (const key in state) {
sum += state[key]
sum += state[key] ?? 0
}
return sum
}
@ -67,7 +67,7 @@ export function computeRollover(
const remaining = gems[gt.key] ?? 0
if (config.rolloverPolicy(gt.key, tier) && remaining > 0) {
kept[gt.key] = Math.round(remaining * 0.25)
expired[gt.key] = remaining - kept[gt.key]
expired[gt.key] = remaining - kept[gt.key]!
} else {
kept[gt.key] = 0
expired[gt.key] = remaining

View file

@ -48,7 +48,7 @@ export const Card = styled.div<{
${(props: { $isRecommended: boolean; $isCurrentTier: boolean; $tierSlug?: TierSlug; theme: DefaultTheme }) => {
if (props.$isCurrentTier) return props.theme.colors.success.main
if (props.$isRecommended) {
const isPremiumTier = props.$tierSlug === 'gold' || props.$tierSlug === 'platinum' || props.$tierSlug === 'diamond'
const isPremiumTier = props.$tierSlug === 'gold' || props.$tierSlug === 'platinum' || (props.$tierSlug as string) === 'iridium'
if (isPremiumTier) return getTierBorderColor(props.$tierSlug, props.theme)
return props.theme.colors.primary.main
}
@ -433,7 +433,7 @@ export const SelectButton = styled.button<{
transform: translateY(-2px);
}
${tierSlug === 'diamond' && css`
${(tierSlug as string) === 'iridium' && css`
animation: ${diamondShimmerKeyframes} 3s ease-in-out infinite;
&::before {

View file

@ -4,7 +4,7 @@
* Data-driven feature list section of a tier card.
*/
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import { Tooltip } from '@lilith/ui-feedback'
import { InfoIcon } from '@lilith/ui-icons'
@ -100,7 +100,7 @@ const FEATURE_DEFS: FeatureDef[] = [
},
]
function parseFeatureText(text: string): JSX.Element {
function parseFeatureText(text: string): ReactNode {
const parts = text.split(/\*\*(.*?)\*\*/)
return (
<>

View file

@ -2,6 +2,8 @@
* TierGrid Component
*
* Responsive grid layout for displaying subscription tiers.
* Fixed-height: always renders Grid, swaps content between skeleton/real.
* No early returns that replace the entire DOM tree.
*/
import type { FC } from 'react'
@ -22,6 +24,8 @@ export interface TierGridProps {
discountPercentage?: number
}
const SKELETON_COUNT = 6
const TierCardSkeleton: FC = () => (
<SkeletonCard aria-hidden="true">
<SkeletonHeader />
@ -44,55 +48,60 @@ export const TierGrid: FC<TierGridProps> = ({
recommendedTierSlug = 'gold',
discountPercentage = 0,
}) => {
if (error) {
return (
<ErrorContainer role="alert" aria-live="assertive">
<ErrorTitle>Unable to load subscription tiers</ErrorTitle>
<ErrorMessage>{error.message}</ErrorMessage>
<RetryButton onClick={() => window.location.reload()}>
Try Again
</RetryButton>
</ErrorContainer>
)
}
if (isLoading) {
return (
<Grid aria-busy="true" aria-label="Loading subscription tiers">
{Array.from({ length: 6 }).map((_, i) => (
<TierCardSkeleton key={i} />
))}
</Grid>
)
}
if (tiers.length === 0) {
return (
<EmptyContainer>
<EmptyTitle>No tiers available</EmptyTitle>
<EmptyMessage>
Subscription tiers are being configured. Please check back later.
</EmptyMessage>
</EmptyContainer>
)
}
return (
<Grid role="list" aria-label="Subscription tiers">
{tiers.map((tier) => (
<TierCard
key={tier.id}
tier={tier}
isCurrentTier={tier.id === currentTierId}
isRecommended={tier.slug === recommendedTierSlug}
onSelect={onSelectTier}
discountPercentage={discountPercentage}
/>
))}
</Grid>
<GridContainer>
{/* Error overlay (positioned over grid, doesn't replace it) */}
{error && (
<ErrorOverlay role="alert" aria-live="assertive">
<ErrorTitle>Unable to load subscription tiers</ErrorTitle>
<ErrorMessage>{error.message}</ErrorMessage>
<RetryButton onClick={() => window.location.reload()}>
Try Again
</RetryButton>
</ErrorOverlay>
)}
{/* Empty state overlay */}
{!isLoading && !error && tiers.length === 0 && (
<EmptyOverlay>
<EmptyTitle>No tiers available</EmptyTitle>
<EmptyMessage>
Subscription tiers are being configured. Please check back later.
</EmptyMessage>
</EmptyOverlay>
)}
{/* Always render the grid for fixed height */}
<Grid
role="list"
aria-label="Subscription tiers"
aria-busy={isLoading}
>
{isLoading
? Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<TierCardSkeleton key={i} />
))
: tiers.map((tier) => (
<TierCard
key={tier.id}
tier={tier}
isCurrentTier={tier.id === currentTierId}
isRecommended={tier.slug === recommendedTierSlug}
onSelect={onSelectTier}
discountPercentage={discountPercentage}
/>
))
}
</Grid>
</GridContainer>
)
}
const GridContainer = styled.div`
position: relative;
min-height: 400px;
`
const Grid = styled.div`
display: grid;
gap: 20px;
@ -167,16 +176,22 @@ const SkeletonButton = styled(SkeletonBase)`
margin-top: auto;
`
const ErrorContainer = styled.div`
const OverlayBase = styled.div`
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
z-index: 1;
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.lg};
`
const ErrorOverlay = styled(OverlayBase)`
background: ${(props: { theme: DefaultTheme }) => props.theme.colors.error.main}10;
border: 1px solid ${(props: { theme: DefaultTheme }) => props.theme.colors.error.main}30;
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.lg};
`
const ErrorTitle = styled.h3`
@ -211,15 +226,8 @@ const RetryButton = styled.button`
}
`
const EmptyContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
const EmptyOverlay = styled(OverlayBase)`
background: ${(props: { theme: DefaultTheme }) => props.theme.colors.background.secondary};
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.lg};
`
const EmptyTitle = styled.h3`

View file

@ -26,7 +26,7 @@ const TIER_COLORS: Record<string, string> = {
silver: '#c0c0c0',
gold: '#ffd700',
platinum: '#e5e4e2',
diamond: '#b9f2ff',
iridium: '#b9f2ff',
}
function tierColor(slug: string): string {
@ -256,7 +256,7 @@ function getMilestone(tier: PlatformSubscriptionTier): string | null {
if (tier.slug === 'platinum' && tier.concierge?.enabled) {
return `Concierge Light — ${tier.concierge.requestsPerWeek} req/wk`
}
if (tier.slug === 'diamond' && tier.concierge?.enabled) {
if (tier.slug === 'iridium' && tier.concierge?.enabled) {
return `Full Concierge — ${tier.concierge.requestsPerWeek} req/wk`
}
return null

View file

@ -26,7 +26,7 @@ const TIER_COLORS: Record<string, string> = {
silver: '#c0c0c0',
gold: '#ffd700',
platinum: '#e5e4e2',
diamond: '#b9f2ff',
iridium: '#b9f2ff',
}
function tierColor(slug: string): string {