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:
parent
ef7e24e53a
commit
c9f0571bdc
14 changed files with 95 additions and 91 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -108,10 +108,7 @@ export function GemStatusBarDemo({ config }: GemStatusBarDemoProps) {
|
|||
</BarsSection>
|
||||
|
||||
<ActivityFeed
|
||||
config={activeConfig}
|
||||
visibleEntries={visibleEntries}
|
||||
phase={phase}
|
||||
monthIndex={monthIndex}
|
||||
/>
|
||||
</ContentGrid>
|
||||
</DemoPanel>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue