refactor(miniapp): add rewrite foundation and demo fixtures

This commit is contained in:
2026-03-11 18:41:36 +04:00
parent d40f5e1d84
commit b193f8ddce
19 changed files with 1073 additions and 490 deletions

View File

@@ -0,0 +1,53 @@
import { For } from 'solid-js'
import type { MiniAppDashboard } from '../../miniapp-api'
import { StatCard } from '../ui'
type SummaryItem = {
label: string
value: string
}
type Props = {
dashboard: MiniAppDashboard
utilityTotalMajor: string
purchaseTotalMajor: string
labels: {
remaining: string
rent: string
utilities: string
purchases: string
}
}
export function FinanceSummaryCards(props: Props) {
const items: SummaryItem[] = [
{
label: props.labels.remaining,
value: `${props.dashboard.totalRemainingMajor} ${props.dashboard.currency}`
},
{
label: props.labels.rent,
value: `${props.dashboard.rentDisplayAmountMajor} ${props.dashboard.currency}`
},
{
label: props.labels.utilities,
value: `${props.utilityTotalMajor} ${props.dashboard.currency}`
},
{
label: props.labels.purchases,
value: `${props.purchaseTotalMajor} ${props.dashboard.currency}`
}
]
return (
<For each={items}>
{(item) => (
<StatCard>
<span>{item.label}</span>
<strong>{item.value}</strong>
</StatCard>
)}
</For>
)
}

View File

@@ -0,0 +1,163 @@
import { For, Match, Switch } from 'solid-js'
import type { MiniAppDashboard } from '../../miniapp-api'
type MemberVisual = {
member: MiniAppDashboard['members'][number]
totalMinor: bigint
barWidthPercent: number
segments: {
key: string
label: string
amountMajor: string
amountMinor: bigint
widthPercent: number
}[]
}
type PurchaseSlice = {
key: string
label: string
amountMajor: string
color: string
percentage: number
dasharray: string
dashoffset: string
}
type Props = {
dashboard: MiniAppDashboard
memberVisuals: readonly MemberVisual[]
purchaseChart: {
totalMajor: string
slices: readonly PurchaseSlice[]
}
labels: {
financeVisualsTitle: string
financeVisualsBody: string
membersCount: string
purchaseInvestmentsTitle: string
purchaseInvestmentsBody: string
purchaseInvestmentsEmpty: string
purchaseTotalLabel: string
purchaseShareLabel: string
}
remainingClass: (member: MiniAppDashboard['members'][number]) => string
}
export function FinanceVisuals(props: Props) {
return (
<>
<article class="balance-item balance-item--wide">
<header>
<strong>{props.labels.financeVisualsTitle}</strong>
<span>
{props.labels.membersCount}: {String(props.dashboard.members.length)}
</span>
</header>
<p>{props.labels.financeVisualsBody}</p>
<div class="member-visual-list">
<For each={props.memberVisuals}>
{(item) => (
<article class="member-visual-card">
<header>
<strong>{item.member.displayName}</strong>
<span class={`balance-status ${props.remainingClass(item.member)}`}>
{item.member.remainingMajor} {props.dashboard.currency}
</span>
</header>
<div class="member-visual-bar">
<div
class="member-visual-bar__track"
style={{ width: `${item.barWidthPercent}%` }}
>
<For each={item.segments}>
{(segment) => (
<span
class={`member-visual-bar__segment member-visual-bar__segment--${segment.key}`}
style={{ width: `${segment.widthPercent}%` }}
/>
)}
</For>
</div>
</div>
<div class="member-visual-meta">
<For each={item.segments}>
{(segment) => (
<span class={`member-visual-chip member-visual-chip--${segment.key}`}>
{segment.label}: {segment.amountMajor} {props.dashboard.currency}
</span>
)}
</For>
</div>
</article>
)}
</For>
</div>
</article>
<article class="balance-item balance-item--wide">
<header>
<strong>{props.labels.purchaseInvestmentsTitle}</strong>
<span>
{props.labels.purchaseTotalLabel}: {props.purchaseChart.totalMajor}{' '}
{props.dashboard.currency}
</span>
</header>
<p>{props.labels.purchaseInvestmentsBody}</p>
<Switch>
<Match when={props.purchaseChart.slices.length === 0}>
<p>{props.labels.purchaseInvestmentsEmpty}</p>
</Match>
<Match when={props.purchaseChart.slices.length > 0}>
<div class="purchase-chart">
<div class="purchase-chart__figure">
<svg class="purchase-chart__donut" viewBox="0 0 120 120" aria-hidden="true">
<circle class="purchase-chart__ring" cx="60" cy="60" r="42" />
<For each={props.purchaseChart.slices}>
{(slice) => (
<circle
class="purchase-chart__slice"
cx="60"
cy="60"
r="42"
stroke={slice.color}
stroke-dasharray={slice.dasharray}
stroke-dashoffset={slice.dashoffset}
/>
)}
</For>
</svg>
<div class="purchase-chart__center">
<span>{props.labels.purchaseTotalLabel}</span>
<strong>
{props.purchaseChart.totalMajor} {props.dashboard.currency}
</strong>
</div>
</div>
<div class="purchase-chart__legend">
<For each={props.purchaseChart.slices}>
{(slice) => (
<article class="purchase-chart__legend-item">
<div>
<span
class="purchase-chart__legend-swatch"
style={{ 'background-color': slice.color }}
/>
<strong>{slice.label}</strong>
</div>
<p>
{slice.amountMajor} {props.dashboard.currency} ·{' '}
{props.labels.purchaseShareLabel} {slice.percentage}%
</p>
</article>
)}
</For>
</div>
</div>
</Match>
</Switch>
</article>
</>
)
}

View File

@@ -0,0 +1,38 @@
import { Show, type JSX } from 'solid-js'
import { Button, MiniChip } from '../ui'
type Props = {
badges: readonly string[]
title: string
body: string
action?:
| {
label: string
onClick: () => void
}
| undefined
}
export function HeroBanner(props: Props): JSX.Element {
return (
<section class="hero-card">
<div class="hero-card__meta">
{props.badges.map((badge, index) => (
<MiniChip muted={index > 0}>{badge}</MiniChip>
))}
</div>
<h2>{props.title}</h2>
<p>{props.body}</p>
<Show when={props.action}>
{(action) => (
<div class="panel-toolbar">
<Button variant="secondary" onClick={() => action().onClick()}>
{action().label}
</Button>
</div>
)}
</Show>
</section>
)
}

View File

@@ -0,0 +1,28 @@
import type { JSX } from 'solid-js'
type TabItem<T extends string> = {
key: T
label: string
}
type Props<T extends string> = {
items: readonly TabItem<T>[]
active: T
onChange: (key: T) => void
}
export function NavigationTabs<T extends string>(props: Props<T>): JSX.Element {
return (
<nav class="nav-grid">
{props.items.map((item) => (
<button
classList={{ 'is-active': props.active === item.key }}
type="button"
onClick={() => props.onChange(item.key)}
>
{item.label}
</button>
))}
</nav>
)
}

View File

@@ -0,0 +1,25 @@
import { Card, MiniChip } from '../ui'
type Props = {
displayName: string
roleLabel: string
statusSummary: string
modeBadge: string
localeBadge: string
}
export function ProfileCard(props: Props) {
return (
<Card class="profile-card" accent>
<header>
<strong>{props.displayName}</strong>
<span>{props.roleLabel}</span>
</header>
<p>{props.statusSummary}</p>
<div class="ledger-compact-card__meta">
<MiniChip>{props.modeBadge}</MiniChip>
<MiniChip muted>{props.localeBadge}</MiniChip>
</div>
</Card>
)
}

View File

@@ -0,0 +1,43 @@
import type { Locale } from '../../i18n'
type Props = {
subtitle: string
title: string
languageLabel: string
locale: Locale
saving: boolean
onChange: (locale: Locale) => void
}
export function TopBar(props: Props) {
return (
<section class="topbar">
<div>
<p class="eyebrow">{props.subtitle}</p>
<h1>{props.title}</h1>
</div>
<label class="locale-switch">
<span>{props.languageLabel}</span>
<div class="locale-switch__buttons">
<button
classList={{ 'is-active': props.locale === 'en' }}
type="button"
disabled={props.saving}
onClick={() => props.onChange('en')}
>
EN
</button>
<button
classList={{ 'is-active': props.locale === 'ru' }}
type="button"
disabled={props.saving}
onClick={() => props.onChange('ru')}
>
RU
</button>
</div>
</label>
</section>
)
}

View File

@@ -1,119 +0,0 @@
import { Show, createEffect, onCleanup, type JSX, type ParentProps } from 'solid-js'
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'icon'
export function Button(
props: ParentProps<{
type?: 'button' | 'submit' | 'reset'
variant?: ButtonVariant
class?: string
disabled?: boolean
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}>
) {
return (
<button
type={props.type ?? 'button'}
class={`ui-button ui-button--${props.variant ?? 'secondary'} ${props.class ?? ''}`.trim()}
disabled={props.disabled}
onClick={props.onClick}
>
{props.children}
</button>
)
}
export function IconButton(
props: ParentProps<{
label: string
class?: string
disabled?: boolean
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}>
) {
const maybeClass = props.class ? { class: props.class } : {}
const maybeDisabled = props.disabled !== undefined ? { disabled: props.disabled } : {}
const maybeOnClick = props.onClick ? { onClick: props.onClick } : {}
return (
<Button variant="icon" {...maybeClass} {...maybeDisabled} {...maybeOnClick}>
<span aria-hidden="true">{props.children}</span>
<span class="sr-only">{props.label}</span>
</Button>
)
}
export function Field(
props: ParentProps<{
label: string
hint?: string
wide?: boolean
class?: string
}>
) {
return (
<label
class={`settings-field ${props.wide ? 'settings-field--wide' : ''} ${props.class ?? ''}`.trim()}
>
<span>{props.label}</span>
{props.children}
<Show when={props.hint}>{(hint) => <small>{hint()}</small>}</Show>
</label>
)
}
export function Modal(
props: ParentProps<{
open: boolean
title: string
description?: string
closeLabel: string
footer?: JSX.Element
onClose: () => void
}>
) {
createEffect(() => {
if (!props.open) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
props.onClose()
}
}
window.addEventListener('keydown', onKeyDown)
onCleanup(() => window.removeEventListener('keydown', onKeyDown))
})
return (
<Show when={props.open}>
<div class="modal-backdrop" onClick={() => props.onClose()}>
<section
class="modal-sheet"
role="dialog"
aria-modal="true"
aria-label={props.title}
onClick={(event) => event.stopPropagation()}
>
<header class="modal-sheet__header">
<div>
<h3>{props.title}</h3>
<Show when={props.description}>{(description) => <p>{description()}</p>}</Show>
</div>
<IconButton label={props.closeLabel} onClick={() => props.onClose()}>
x
</IconButton>
</header>
<div class="modal-sheet__body">{props.children}</div>
<Show when={props.footer}>
{(footer) => <footer class="modal-sheet__footer">{footer()}</footer>}
</Show>
</section>
</div>
</Show>
)
}

View File

@@ -0,0 +1,60 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { JSX, ParentProps } from 'solid-js'
import { cn } from '../../lib/cn'
const buttonVariants = cva('ui-button', {
variants: {
variant: {
primary: 'ui-button--primary',
secondary: 'ui-button--secondary',
danger: 'ui-button--danger',
ghost: 'ui-button--ghost',
icon: 'ui-button--icon'
}
},
defaultVariants: {
variant: 'secondary'
}
})
type ButtonProps = ParentProps<{
type?: 'button' | 'submit' | 'reset'
class?: string
disabled?: boolean
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}> &
VariantProps<typeof buttonVariants>
export function Button(props: ButtonProps) {
return (
<button
type={props.type ?? 'button'}
class={cn(buttonVariants({ variant: props.variant }), props.class)}
disabled={props.disabled}
onClick={props.onClick}
>
{props.children}
</button>
)
}
export function IconButton(
props: ParentProps<{
label: string
class?: string
disabled?: boolean
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}>
) {
const maybeClass = props.class ? { class: props.class } : {}
const maybeDisabled = props.disabled !== undefined ? { disabled: props.disabled } : {}
const maybeOnClick = props.onClick ? { onClick: props.onClick } : {}
return (
<Button variant="icon" {...maybeClass} {...maybeDisabled} {...maybeOnClick}>
<span aria-hidden="true">{props.children}</span>
<span class="sr-only">{props.label}</span>
</Button>
)
}

View File

@@ -0,0 +1,23 @@
import type { ParentProps } from 'solid-js'
import { cn } from '../../lib/cn'
export function Card(props: ParentProps<{ class?: string; accent?: boolean }>) {
return (
<article class={cn('balance-item', props.accent && 'balance-item--accent', props.class)}>
{props.children}
</article>
)
}
export function StatCard(props: ParentProps<{ class?: string }>) {
return <article class={cn('stat-card', props.class)}>{props.children}</article>
}
export function MiniChip(props: ParentProps<{ muted?: boolean; class?: string }>) {
return (
<span class={cn('mini-chip', props.muted && 'mini-chip--muted', props.class)}>
{props.children}
</span>
)
}

View File

@@ -0,0 +1,41 @@
import * as Dialog from '@kobalte/core/dialog'
import { Show, type JSX, type ParentProps } from 'solid-js'
export function Modal(
props: ParentProps<{
open: boolean
title: string
description?: string
closeLabel: string
footer?: JSX.Element
onClose: () => void
}>
) {
return (
<Dialog.Root open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-backdrop" />
<div class="modal-backdrop">
<Dialog.Content class="modal-sheet" aria-label={props.title}>
<header class="modal-sheet__header">
<div>
<Dialog.Title>{props.title}</Dialog.Title>
<Show when={props.description}>
{(description) => <Dialog.Description>{description()}</Dialog.Description>}
</Show>
</div>
<Dialog.CloseButton class="ui-button ui-button--icon">
<span aria-hidden="true">x</span>
<span class="sr-only">{props.closeLabel}</span>
</Dialog.CloseButton>
</header>
<div class="modal-sheet__body">{props.children}</div>
<Show when={props.footer}>
{(footer) => <footer class="modal-sheet__footer">{footer()}</footer>}
</Show>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
)
}

View File

@@ -0,0 +1,20 @@
import { Show, type ParentProps } from 'solid-js'
import { cn } from '../../lib/cn'
export function Field(
props: ParentProps<{
label: string
hint?: string
wide?: boolean
class?: string
}>
) {
return (
<label class={cn('settings-field', props.wide && 'settings-field--wide', props.class)}>
<span>{props.label}</span>
{props.children}
<Show when={props.hint}>{(hint) => <small>{hint()}</small>}</Show>
</label>
)
}

View File

@@ -0,0 +1,4 @@
export * from './button'
export * from './card'
export * from './dialog'
export * from './field'