mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 01:24:03 +00:00
refactor(miniapp): add rewrite foundation and demo fixtures
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
163
apps/miniapp/src/components/finance/finance-visuals.tsx
Normal file
163
apps/miniapp/src/components/finance/finance-visuals.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
38
apps/miniapp/src/components/layout/hero-banner.tsx
Normal file
38
apps/miniapp/src/components/layout/hero-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
apps/miniapp/src/components/layout/navigation-tabs.tsx
Normal file
28
apps/miniapp/src/components/layout/navigation-tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
apps/miniapp/src/components/layout/profile-card.tsx
Normal file
25
apps/miniapp/src/components/layout/profile-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
apps/miniapp/src/components/layout/top-bar.tsx
Normal file
43
apps/miniapp/src/components/layout/top-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
60
apps/miniapp/src/components/ui/button.tsx
Normal file
60
apps/miniapp/src/components/ui/button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/miniapp/src/components/ui/card.tsx
Normal file
23
apps/miniapp/src/components/ui/card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
apps/miniapp/src/components/ui/dialog.tsx
Normal file
41
apps/miniapp/src/components/ui/dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
apps/miniapp/src/components/ui/field.tsx
Normal file
20
apps/miniapp/src/components/ui/field.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
apps/miniapp/src/components/ui/index.ts
Normal file
4
apps/miniapp/src/components/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './button'
|
||||
export * from './card'
|
||||
export * from './dialog'
|
||||
export * from './field'
|
||||
Reference in New Issue
Block a user