mirror of
https://github.com/whekin/household-bot.git
synced 2026-04-01 01:24:03 +00:00
feat(miniapp): refine UI and add utility bill management
- Fix collapsible padding and button spacing - Add subtotal to balance card - Add utility bill management for admins - Fix lints and type checks across the monorepo - Implement rejectPendingHouseholdMember in repository and service
This commit is contained in:
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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">
|
||||
<strong>{props.purchaseChart.totalMajor}</strong>
|
||||
<small>{props.dashboard.currency}</small>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
import { cn } from '../../lib/cn'
|
||||
import { formatCyclePeriod, formatFriendlyDate } from '../../lib/dates'
|
||||
import { majorStringToMinor, sumMajorStrings } from '../../lib/money'
|
||||
import type { MiniAppDashboard } from '../../miniapp-api'
|
||||
import { MiniChip, StatCard } from '../ui'
|
||||
|
||||
type Props = {
|
||||
copy: Record<string, string | undefined>
|
||||
locale: 'en' | 'ru'
|
||||
dashboard: MiniAppDashboard
|
||||
member: MiniAppDashboard['members'][number]
|
||||
detail?: boolean
|
||||
}
|
||||
|
||||
export function MemberBalanceCard(props: Props) {
|
||||
const utilitiesAdjustedMajor = () =>
|
||||
sumMajorStrings(props.member.utilityShareMajor, props.member.purchaseOffsetMajor)
|
||||
|
||||
const adjustmentClass = () => {
|
||||
const value = majorStringToMinor(props.member.purchaseOffsetMajor)
|
||||
|
||||
if (value < 0n) {
|
||||
return 'is-credit'
|
||||
}
|
||||
|
||||
if (value > 0n) {
|
||||
return 'is-due'
|
||||
}
|
||||
|
||||
return 'is-settled'
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
class={cn(
|
||||
'balance-item',
|
||||
'balance-item--accent',
|
||||
'balance-spotlight',
|
||||
props.detail && 'balance-spotlight--detail'
|
||||
)}
|
||||
>
|
||||
<header class="balance-spotlight__header">
|
||||
<div class="balance-spotlight__copy">
|
||||
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
|
||||
<Show when={props.copy.yourBalanceBody}>{(body) => <p>{body()}</p>}</Show>
|
||||
</div>
|
||||
<div class="balance-spotlight__hero">
|
||||
<span>{props.copy.remainingLabel ?? ''}</span>
|
||||
<strong>
|
||||
{props.member.remainingMajor} {props.dashboard.currency}
|
||||
</strong>
|
||||
<Show when={majorStringToMinor(props.member.paidMajor) > 0n}>
|
||||
<small>
|
||||
{props.copy.totalDue ?? ''}: {props.member.netDueMajor} {props.dashboard.currency}
|
||||
</small>
|
||||
</Show>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="balance-spotlight__stats">
|
||||
<StatCard class="balance-spotlight__stat">
|
||||
<span>{props.copy.currentCycleLabel ?? ''}</span>
|
||||
<strong>{formatCyclePeriod(props.dashboard.period, props.locale)}</strong>
|
||||
</StatCard>
|
||||
<StatCard class="balance-spotlight__stat">
|
||||
<span>{props.copy.paidLabel ?? ''}</span>
|
||||
<strong>
|
||||
{props.member.paidMajor} {props.dashboard.currency}
|
||||
</strong>
|
||||
</StatCard>
|
||||
<StatCard class="balance-spotlight__stat">
|
||||
<span>{props.copy.remainingLabel ?? ''}</span>
|
||||
<strong>
|
||||
{props.member.remainingMajor} {props.dashboard.currency}
|
||||
</strong>
|
||||
</StatCard>
|
||||
</div>
|
||||
|
||||
<div class="balance-spotlight__rows">
|
||||
<article class="balance-detail-row">
|
||||
<div class="balance-detail-row__main">
|
||||
<span>{props.copy.shareRent ?? ''}</span>
|
||||
<strong>
|
||||
{props.member.rentShareMajor} {props.dashboard.currency}
|
||||
</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="balance-detail-row">
|
||||
<div class="balance-detail-row__main">
|
||||
<span>{props.copy.pureUtilitiesLabel ?? props.copy.shareUtilities ?? ''}</span>
|
||||
<strong>
|
||||
{props.member.utilityShareMajor} {props.dashboard.currency}
|
||||
</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="balance-detail-row">
|
||||
<div class="balance-detail-row__main">
|
||||
<span>{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset ?? ''}</span>
|
||||
<strong class={`balance-status ${adjustmentClass()}`}>
|
||||
{props.member.purchaseOffsetMajor} {props.dashboard.currency}
|
||||
</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<Show when={props.dashboard.paymentBalanceAdjustmentPolicy === 'utilities'}>
|
||||
<article class="balance-detail-row balance-detail-row--accent">
|
||||
<div class="balance-detail-row__main">
|
||||
<span>{props.copy.utilitiesAdjustedTotalLabel ?? ''}</span>
|
||||
<strong>
|
||||
{utilitiesAdjustedMajor()} {props.dashboard.currency}
|
||||
</strong>
|
||||
</div>
|
||||
</article>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.dashboard.rentSourceCurrency !== props.dashboard.currency}>
|
||||
<section class="fx-panel">
|
||||
<header class="fx-panel__header">
|
||||
<strong>{props.copy.rentFxTitle ?? ''}</strong>
|
||||
<Show when={props.dashboard.rentFxEffectiveDate}>
|
||||
{(date) => (
|
||||
<MiniChip muted>
|
||||
{props.copy.fxEffectiveDateLabel ?? ''}:{' '}
|
||||
{formatFriendlyDate(date(), props.locale)}
|
||||
</MiniChip>
|
||||
)}
|
||||
</Show>
|
||||
</header>
|
||||
|
||||
<div class="fx-panel__grid">
|
||||
<article class="fx-panel__cell">
|
||||
<span>{props.copy.sourceAmountLabel ?? ''}</span>
|
||||
<strong>
|
||||
{props.dashboard.rentSourceAmountMajor} {props.dashboard.rentSourceCurrency}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="fx-panel__cell">
|
||||
<span>{props.copy.settlementAmountLabel ?? ''}</span>
|
||||
<strong>
|
||||
{props.dashboard.rentDisplayAmountMajor} {props.dashboard.currency}
|
||||
</strong>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +1,45 @@
|
||||
import type { JSX } from 'solid-js'
|
||||
import { useNavigate, useLocation } from '@solidjs/router'
|
||||
import { Home, Wallet, BookOpen } from 'lucide-solid'
|
||||
import { type JSX } from 'solid-js'
|
||||
|
||||
type TabItem<T extends string> = {
|
||||
key: T
|
||||
import { useI18n } from '../../contexts/i18n-context'
|
||||
|
||||
type TabItem = {
|
||||
path: string
|
||||
label: string
|
||||
icon?: JSX.Element
|
||||
icon: JSX.Element
|
||||
}
|
||||
|
||||
type Props<T extends string> = {
|
||||
items: readonly TabItem<T>[]
|
||||
active: T
|
||||
onChange: (key: T) => void
|
||||
}
|
||||
/**
|
||||
* Bottom navigation bar with 3 tabs (Bug #6 fix: reduced to 3 tabs,
|
||||
* settings moved to top bar gear icon).
|
||||
*/
|
||||
export function NavigationTabs(): JSX.Element {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { copy } = useI18n()
|
||||
|
||||
const tabs = (): TabItem[] => [
|
||||
{ path: '/', label: copy().home, icon: <Home size={20} /> },
|
||||
{ path: '/balances', label: copy().balances, icon: <Wallet size={20} /> },
|
||||
{ path: '/ledger', label: copy().ledger, icon: <BookOpen size={20} /> }
|
||||
]
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') return location.pathname === '/'
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
export function NavigationTabs<T extends string>(props: Props<T>): JSX.Element {
|
||||
return (
|
||||
<nav class="nav-grid">
|
||||
{props.items.map((item) => (
|
||||
{tabs().map((tab) => (
|
||||
<button
|
||||
classList={{ 'is-active': props.active === item.key }}
|
||||
classList={{ 'is-active': isActive(tab.path) }}
|
||||
type="button"
|
||||
onClick={() => props.onChange(item.key)}
|
||||
onClick={() => navigate(tab.path)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
164
apps/miniapp/src/components/layout/shell.tsx
Normal file
164
apps/miniapp/src/components/layout/shell.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useNavigate } from '@solidjs/router'
|
||||
import { Show, createSignal, type ParentProps } from 'solid-js'
|
||||
import { Settings } from 'lucide-solid'
|
||||
|
||||
import { useSession } from '../../contexts/session-context'
|
||||
import { useI18n } from '../../contexts/i18n-context'
|
||||
import { useDashboard } from '../../contexts/dashboard-context'
|
||||
import { NavigationTabs } from './navigation-tabs'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button, IconButton } from '../ui/button'
|
||||
import { Modal } from '../ui/dialog'
|
||||
|
||||
export function AppShell(props: ParentProps) {
|
||||
const { readySession } = useSession()
|
||||
const { copy, locale, setLocale } = useI18n()
|
||||
const { effectiveIsAdmin, testingRolePreview, setTestingRolePreview } = useDashboard()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [testingSurfaceOpen, setTestingSurfaceOpen] = createSignal(false)
|
||||
|
||||
function memberStatusLabel(status: 'active' | 'away' | 'left') {
|
||||
const labels = {
|
||||
active: copy().memberStatusActive,
|
||||
away: copy().memberStatusAway,
|
||||
left: copy().memberStatusLeft
|
||||
}
|
||||
return labels[status]
|
||||
}
|
||||
|
||||
let tapCount = 0
|
||||
let tapTimer: ReturnType<typeof setTimeout> | undefined
|
||||
function handleRoleChipTap() {
|
||||
tapCount++
|
||||
if (tapCount >= 5) {
|
||||
setTestingSurfaceOpen(true)
|
||||
tapCount = 0
|
||||
}
|
||||
clearTimeout(tapTimer)
|
||||
tapTimer = setTimeout(() => {
|
||||
tapCount = 0
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<main class="shell">
|
||||
{/* ── Top bar ──────────────────────────────────── */}
|
||||
<section class="topbar">
|
||||
<div class="topbar__copy">
|
||||
<p class="eyebrow">{copy().appSubtitle}</p>
|
||||
<h1>{readySession()?.member.householdName ?? copy().appTitle}</h1>
|
||||
</div>
|
||||
|
||||
<div class="topbar__actions">
|
||||
<div class="locale-switch locale-switch--compact">
|
||||
<div class="locale-switch__buttons">
|
||||
<button
|
||||
classList={{ 'is-active': locale() === 'en' }}
|
||||
type="button"
|
||||
onClick={() => setLocale('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
classList={{ 'is-active': locale() === 'ru' }}
|
||||
type="button"
|
||||
onClick={() => setLocale('ru')}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton label="Settings" onClick={() => navigate('/settings')}>
|
||||
<Settings size={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Context badges ───────────────────────────── */}
|
||||
<section class="app-context-row">
|
||||
<div class="app-context-meta">
|
||||
<Badge variant={readySession()?.mode === 'demo' ? 'accent' : 'default'}>
|
||||
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
|
||||
</Badge>
|
||||
<Show
|
||||
when={readySession()?.member.isAdmin}
|
||||
fallback={
|
||||
<Badge variant="muted">
|
||||
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<button class="ui-badge ui-badge--muted" onClick={handleRoleChipTap}>
|
||||
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
|
||||
</button>
|
||||
</Show>
|
||||
<Badge variant="muted">
|
||||
{readySession()?.member.status
|
||||
? memberStatusLabel(readySession()!.member.status)
|
||||
: copy().memberStatusActive}
|
||||
</Badge>
|
||||
<Show when={testingRolePreview()}>
|
||||
{(preview) => (
|
||||
<Badge variant="accent">
|
||||
{`${copy().testingViewBadge ?? ''}: ${preview() === 'admin' ? copy().adminTag : copy().residentTag}`}
|
||||
</Badge>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Route content ────────────────────────────── */}
|
||||
<section class="content-stack">{props.children}</section>
|
||||
|
||||
{/* ── Bottom nav (Bug #6: 3 tabs, proper padding) */}
|
||||
<div class="app-bottom-nav">
|
||||
<NavigationTabs />
|
||||
</div>
|
||||
|
||||
{/* ── Modals at route/shell level (Bug #1/#2 fix) */}
|
||||
<Modal
|
||||
open={testingSurfaceOpen()}
|
||||
title={copy().testingSurfaceTitle ?? ''}
|
||||
description={copy().testingSurfaceBody}
|
||||
closeLabel={copy().closeEditorAction}
|
||||
onClose={() => setTestingSurfaceOpen(false)}
|
||||
footer={
|
||||
<div class="modal-action-row">
|
||||
<Button variant="ghost" onClick={() => setTestingSurfaceOpen(false)}>
|
||||
{copy().closeEditorAction}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setTestingRolePreview(null)}>
|
||||
{copy().testingUseRealRoleAction ?? ''}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="testing-card">
|
||||
<article class="testing-card__section">
|
||||
<span>{copy().testingCurrentRoleLabel ?? ''}</span>
|
||||
<strong>{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}</strong>
|
||||
</article>
|
||||
<article class="testing-card__section">
|
||||
<span>{copy().testingPreviewRoleLabel ?? ''}</span>
|
||||
<strong>
|
||||
{testingRolePreview()
|
||||
? testingRolePreview() === 'admin'
|
||||
? copy().adminTag
|
||||
: copy().residentTag
|
||||
: copy().testingUseRealRoleAction}
|
||||
</strong>
|
||||
</article>
|
||||
<div class="testing-card__actions">
|
||||
<Button variant="secondary" onClick={() => setTestingRolePreview('admin')}>
|
||||
{copy().testingPreviewAdminAction ?? ''}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setTestingRolePreview('resident')}>
|
||||
{copy().testingPreviewResidentAction ?? ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { Locale } from '../../i18n'
|
||||
import { GlobeIcon } from '../ui'
|
||||
|
||||
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 class="topbar__copy">
|
||||
<p class="eyebrow">{props.subtitle}</p>
|
||||
<h1>{props.title}</h1>
|
||||
</div>
|
||||
|
||||
<div class="locale-switch locale-switch--compact">
|
||||
<span class="locale-switch__label sr-only">{props.languageLabel}</span>
|
||||
<div class="locale-switch__buttons">
|
||||
<span class="locale-switch__icon" aria-hidden="true">
|
||||
<GlobeIcon />
|
||||
</span>
|
||||
<button
|
||||
classList={{ 'is-active': props.locale === 'en' }}
|
||||
type="button"
|
||||
disabled={props.saving}
|
||||
aria-label={`${props.languageLabel}: English`}
|
||||
onClick={() => props.onChange('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
classList={{ 'is-active': props.locale === 'ru' }}
|
||||
type="button"
|
||||
disabled={props.saving}
|
||||
aria-label={`${props.languageLabel}: Russian`}
|
||||
onClick={() => props.onChange('ru')}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
26
apps/miniapp/src/components/ui/badge.tsx
Normal file
26
apps/miniapp/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ParentProps } from 'solid-js'
|
||||
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
type BadgeProps = ParentProps<{
|
||||
variant?: 'default' | 'muted' | 'accent' | 'danger'
|
||||
class?: string
|
||||
}>
|
||||
|
||||
export function Badge(props: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
class={cn(
|
||||
'ui-badge',
|
||||
{
|
||||
'ui-badge--muted': props.variant === 'muted',
|
||||
'ui-badge--accent': props.variant === 'accent',
|
||||
'ui-badge--danger': props.variant === 'danger'
|
||||
},
|
||||
props.class
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type { JSX, ParentProps } from 'solid-js'
|
||||
import { Show, type JSX, type ParentProps } from 'solid-js'
|
||||
import { Loader2 } from 'lucide-solid'
|
||||
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
@@ -11,10 +12,16 @@ const buttonVariants = cva('ui-button', {
|
||||
danger: 'ui-button--danger',
|
||||
ghost: 'ui-button--ghost',
|
||||
icon: 'ui-button--icon'
|
||||
},
|
||||
size: {
|
||||
sm: 'ui-button--sm',
|
||||
md: '',
|
||||
lg: 'ui-button--lg'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'secondary'
|
||||
variant: 'secondary',
|
||||
size: 'md'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,6 +29,7 @@ type ButtonProps = ParentProps<{
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
}> &
|
||||
VariantProps<typeof buttonVariants>
|
||||
@@ -30,10 +38,13 @@ export function Button(props: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={props.type ?? 'button'}
|
||||
class={cn(buttonVariants({ variant: props.variant }), props.class)}
|
||||
disabled={props.disabled}
|
||||
class={cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)}
|
||||
disabled={props.disabled || props.loading}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Show when={props.loading}>
|
||||
<Loader2 class="ui-button__spinner" size={16} />
|
||||
</Show>
|
||||
{props.children}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -2,9 +2,19 @@ import type { ParentProps } from 'solid-js'
|
||||
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
export function Card(props: ParentProps<{ class?: string; accent?: boolean }>) {
|
||||
export function Card(
|
||||
props: ParentProps<{ class?: string; accent?: boolean; muted?: boolean; wide?: boolean }>
|
||||
) {
|
||||
return (
|
||||
<article class={cn('balance-item', props.accent && 'balance-item--accent', props.class)}>
|
||||
<article
|
||||
class={cn(
|
||||
'ui-card',
|
||||
props.accent && 'ui-card--accent',
|
||||
props.muted && 'ui-card--muted',
|
||||
props.wide && 'ui-card--wide',
|
||||
props.class
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</article>
|
||||
)
|
||||
@@ -14,6 +24,7 @@ export function StatCard(props: ParentProps<{ class?: string }>) {
|
||||
return <article class={cn('stat-card', props.class)}>{props.children}</article>
|
||||
}
|
||||
|
||||
/** @deprecated Use Badge component instead */
|
||||
export function MiniChip(props: ParentProps<{ muted?: boolean; class?: string }>) {
|
||||
return (
|
||||
<span class={cn('mini-chip', props.muted && 'mini-chip--muted', props.class)}>
|
||||
|
||||
32
apps/miniapp/src/components/ui/collapsible.tsx
Normal file
32
apps/miniapp/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as CollapsiblePrimitive from '@kobalte/core/collapsible'
|
||||
import { Show, type ParentProps } from 'solid-js'
|
||||
import { ChevronDown } from 'lucide-solid'
|
||||
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
type CollapsibleProps = ParentProps<{
|
||||
title: string
|
||||
body?: string
|
||||
defaultOpen?: boolean
|
||||
class?: string
|
||||
}>
|
||||
|
||||
export function Collapsible(props: CollapsibleProps) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Root
|
||||
{...(props.defaultOpen !== undefined ? { defaultOpen: props.defaultOpen } : {})}
|
||||
class={cn('ui-collapsible', props.class)}
|
||||
>
|
||||
<CollapsiblePrimitive.Trigger class="ui-collapsible__trigger">
|
||||
<div class="ui-collapsible__copy">
|
||||
<strong>{props.title}</strong>
|
||||
<Show when={props.body}>{(body) => <p>{body()}</p>}</Show>
|
||||
</div>
|
||||
<ChevronDown class="ui-collapsible__chevron" size={18} />
|
||||
</CollapsiblePrimitive.Trigger>
|
||||
<CollapsiblePrimitive.Content class="ui-collapsible__content">
|
||||
{props.children}
|
||||
</CollapsiblePrimitive.Content>
|
||||
</CollapsiblePrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -11,10 +11,10 @@ export function Field(
|
||||
}>
|
||||
) {
|
||||
return (
|
||||
<label class={cn('settings-field', props.wide && 'settings-field--wide', props.class)}>
|
||||
<span>{props.label}</span>
|
||||
<label class={cn('ui-field', props.wide && 'ui-field--wide', props.class)}>
|
||||
<span class="ui-field__label">{props.label}</span>
|
||||
{props.children}
|
||||
<Show when={props.hint}>{(hint) => <small>{hint()}</small>}</Show>
|
||||
<Show when={props.hint}>{(hint) => <small class="ui-field__hint">{hint()}</small>}</Show>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,3 +3,9 @@ export * from './card'
|
||||
export * from './dialog'
|
||||
export * from './field'
|
||||
export * from './icons'
|
||||
export * from './input'
|
||||
export * from './select'
|
||||
export * from './toggle'
|
||||
export * from './collapsible'
|
||||
export * from './badge'
|
||||
export * from './skeleton'
|
||||
|
||||
65
apps/miniapp/src/components/ui/input.tsx
Normal file
65
apps/miniapp/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { JSX } from 'solid-js'
|
||||
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
type InputProps = {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
type?: 'text' | 'number' | 'email'
|
||||
min?: string | number
|
||||
max?: string | number
|
||||
step?: string | number
|
||||
maxlength?: number
|
||||
disabled?: boolean
|
||||
invalid?: boolean
|
||||
class?: string
|
||||
style?: JSX.CSSProperties
|
||||
list?: string
|
||||
id?: string
|
||||
onInput?: JSX.EventHandlerUnion<HTMLInputElement, InputEvent>
|
||||
onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event>
|
||||
}
|
||||
|
||||
export function Input(props: InputProps) {
|
||||
return (
|
||||
<input
|
||||
type={props.type ?? 'text'}
|
||||
value={props.value ?? ''}
|
||||
placeholder={props.placeholder}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
maxlength={props.maxlength}
|
||||
disabled={props.disabled}
|
||||
aria-invalid={props.invalid}
|
||||
style={props.style}
|
||||
list={props.list}
|
||||
id={props.id}
|
||||
class={cn('ui-input', props.class)}
|
||||
onInput={props.onInput}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Textarea(props: {
|
||||
value?: string
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
maxlength?: number
|
||||
disabled?: boolean
|
||||
class?: string
|
||||
onInput?: JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent>
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
value={props.value ?? ''}
|
||||
placeholder={props.placeholder}
|
||||
rows={props.rows ?? 4}
|
||||
maxlength={props.maxlength}
|
||||
disabled={props.disabled}
|
||||
class={cn('ui-input ui-textarea', props.class)}
|
||||
onInput={props.onInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
66
apps/miniapp/src/components/ui/select.tsx
Normal file
66
apps/miniapp/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as SelectPrimitive from '@kobalte/core/select'
|
||||
import { Check, ChevronDown } from 'lucide-solid'
|
||||
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type SelectProps = {
|
||||
value?: string
|
||||
options: readonly SelectOption[]
|
||||
disabled?: boolean
|
||||
class?: string
|
||||
id?: string
|
||||
ariaLabel: string
|
||||
placeholder?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export function Select(props: SelectProps) {
|
||||
const selectedOption = () =>
|
||||
props.options.find((option) => option.value === (props.value ?? '')) ?? null
|
||||
const optionalRootProps = {
|
||||
...(props.disabled !== undefined ? { disabled: props.disabled } : {}),
|
||||
...(props.id !== undefined ? { id: props.id } : {}),
|
||||
...(props.placeholder !== undefined ? { placeholder: props.placeholder } : {})
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root<SelectOption>
|
||||
value={selectedOption()}
|
||||
options={[...props.options]}
|
||||
optionValue="value"
|
||||
optionTextValue="label"
|
||||
onChange={(option) => props.onChange?.(option?.value ?? '')}
|
||||
itemComponent={(itemProps) => (
|
||||
<SelectPrimitive.Item item={itemProps.item} class="ui-select__item">
|
||||
<SelectPrimitive.ItemLabel class="ui-select__item-label">
|
||||
{itemProps.item.rawValue.label}
|
||||
</SelectPrimitive.ItemLabel>
|
||||
<SelectPrimitive.ItemIndicator class="ui-select__item-indicator">
|
||||
<Check size={14} />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)}
|
||||
{...optionalRootProps}
|
||||
>
|
||||
<SelectPrimitive.HiddenSelect />
|
||||
<SelectPrimitive.Trigger class={cn('ui-select', props.class)} aria-label={props.ariaLabel}>
|
||||
<SelectPrimitive.Value<SelectOption> class="ui-select__value">
|
||||
{(state) => state.selectedOption()?.label ?? props.placeholder ?? ''}
|
||||
</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon class="ui-select__icon">
|
||||
<ChevronDown size={16} />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content class="ui-select__content">
|
||||
<SelectPrimitive.Listbox class="ui-select__listbox" />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
)
|
||||
}
|
||||
19
apps/miniapp/src/components/ui/skeleton.tsx
Normal file
19
apps/miniapp/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
type SkeletonProps = {
|
||||
class?: string
|
||||
width?: string
|
||||
height?: string
|
||||
}
|
||||
|
||||
export function Skeleton(props: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
class={cn('ui-skeleton', props.class)}
|
||||
style={{
|
||||
width: props.width,
|
||||
height: props.height
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
29
apps/miniapp/src/components/ui/toggle.tsx
Normal file
29
apps/miniapp/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as SwitchPrimitive from '@kobalte/core/switch'
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
type ToggleProps = {
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
class?: string
|
||||
onChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
export function Toggle(props: ToggleProps) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
checked={props.checked}
|
||||
{...(props.disabled !== undefined ? { disabled: props.disabled } : {})}
|
||||
onChange={props.onChange}
|
||||
class={cn('ui-toggle', props.class)}
|
||||
>
|
||||
<SwitchPrimitive.Input />
|
||||
<SwitchPrimitive.Control class="ui-toggle__track">
|
||||
<SwitchPrimitive.Thumb class="ui-toggle__thumb" />
|
||||
</SwitchPrimitive.Control>
|
||||
{props.label && (
|
||||
<SwitchPrimitive.Label class="ui-toggle__label">{props.label}</SwitchPrimitive.Label>
|
||||
)}
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user