mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:54:02 +00:00
refactor(miniapp): add rewrite foundation and demo fixtures
This commit is contained in:
@@ -10,8 +10,13 @@
|
||||
"lint": "oxlint \"src\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@tanstack/solid-query": "5.90.23",
|
||||
"@twa-dev/sdk": "8.0.2",
|
||||
"solid-js": "^1.9.9"
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"solid-js": "^1.9.9",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
|
||||
@@ -36,6 +36,20 @@ import {
|
||||
type MiniAppPendingMember
|
||||
} from './miniapp-api'
|
||||
import { Button, Field, IconButton, Modal } from './components/ui'
|
||||
import { HeroBanner } from './components/layout/hero-banner'
|
||||
import { NavigationTabs } from './components/layout/navigation-tabs'
|
||||
import { ProfileCard } from './components/layout/profile-card'
|
||||
import { TopBar } from './components/layout/top-bar'
|
||||
import { FinanceSummaryCards } from './components/finance/finance-summary-cards'
|
||||
import { FinanceVisuals } from './components/finance/finance-visuals'
|
||||
import {
|
||||
demoAdminSettings,
|
||||
demoCycleState,
|
||||
demoDashboard,
|
||||
demoMember,
|
||||
demoPendingMembers,
|
||||
demoTelegramUser
|
||||
} from './demo/miniapp-demo'
|
||||
import { getTelegramWebApp } from './telegram-webapp'
|
||||
|
||||
type SessionState =
|
||||
@@ -106,19 +120,8 @@ const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7
|
||||
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||
status: 'ready',
|
||||
mode: 'demo',
|
||||
member: {
|
||||
id: 'demo-member',
|
||||
displayName: 'Demo Resident',
|
||||
status: 'active',
|
||||
isAdmin: false,
|
||||
preferredLocale: 'en',
|
||||
householdDefaultLocale: 'en'
|
||||
},
|
||||
telegramUser: {
|
||||
firstName: 'Demo',
|
||||
username: 'demo_user',
|
||||
languageCode: 'en'
|
||||
}
|
||||
member: demoMember,
|
||||
telegramUser: demoTelegramUser
|
||||
}
|
||||
|
||||
function detectLocale(): Locale {
|
||||
@@ -952,6 +955,68 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyDemoState() {
|
||||
setDisplayNameDraft(demoSession.member.displayName)
|
||||
setSession(demoSession)
|
||||
setDashboard(demoDashboard)
|
||||
setPendingMembers([...demoPendingMembers])
|
||||
setAdminSettings(demoAdminSettings)
|
||||
setCycleState(demoCycleState)
|
||||
setPurchaseDraftMap(purchaseDrafts(demoDashboard.ledger))
|
||||
setPaymentDraftMap(paymentDrafts(demoDashboard.ledger))
|
||||
setMemberDisplayNameDrafts(
|
||||
Object.fromEntries(demoAdminSettings.members.map((member) => [member.id, member.displayName]))
|
||||
)
|
||||
setRentWeightDrafts(
|
||||
Object.fromEntries(
|
||||
demoAdminSettings.members.map((member) => [member.id, String(member.rentShareWeight)])
|
||||
)
|
||||
)
|
||||
setMemberStatusDrafts(
|
||||
Object.fromEntries(demoAdminSettings.members.map((member) => [member.id, member.status]))
|
||||
)
|
||||
setMemberAbsencePolicyDrafts(
|
||||
Object.fromEntries(
|
||||
demoAdminSettings.members.map((member) => [
|
||||
member.id,
|
||||
resolvedMemberAbsencePolicy(member.id, member.status, demoAdminSettings).policy
|
||||
])
|
||||
)
|
||||
)
|
||||
setBillingForm({
|
||||
settlementCurrency: demoAdminSettings.settings.settlementCurrency,
|
||||
paymentBalanceAdjustmentPolicy: demoAdminSettings.settings.paymentBalanceAdjustmentPolicy,
|
||||
rentAmountMajor: demoAdminSettings.settings.rentAmountMinor
|
||||
? (Number(demoAdminSettings.settings.rentAmountMinor) / 100).toFixed(2)
|
||||
: '',
|
||||
rentCurrency: demoAdminSettings.settings.rentCurrency,
|
||||
rentDueDay: demoAdminSettings.settings.rentDueDay,
|
||||
rentWarningDay: demoAdminSettings.settings.rentWarningDay,
|
||||
utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay,
|
||||
utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay,
|
||||
timezone: demoAdminSettings.settings.timezone
|
||||
})
|
||||
setCycleForm((current) => ({
|
||||
...current,
|
||||
period: demoCycleState.cycle?.period ?? current.period,
|
||||
rentCurrency: demoAdminSettings.settings.rentCurrency,
|
||||
utilityCurrency: demoAdminSettings.settings.settlementCurrency,
|
||||
rentAmountMajor: demoAdminSettings.settings.rentAmountMinor
|
||||
? (Number(demoAdminSettings.settings.rentAmountMinor) / 100).toFixed(2)
|
||||
: '',
|
||||
utilityCategorySlug:
|
||||
demoAdminSettings.categories.find((category) => category.isActive)?.slug ?? '',
|
||||
utilityAmountMajor: ''
|
||||
}))
|
||||
setPaymentForm({
|
||||
memberId: demoAdminSettings.members[0]?.id ?? '',
|
||||
kind: 'rent',
|
||||
amountMajor: '',
|
||||
currency: demoAdminSettings.settings.settlementCurrency
|
||||
})
|
||||
setUtilityBillDrafts(cycleUtilityBillDrafts(demoCycleState.utilityBills))
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const fallbackLocale = detectLocale()
|
||||
setLocale(fallbackLocale)
|
||||
@@ -962,7 +1027,7 @@ function App() {
|
||||
const initData = webApp?.initData?.trim()
|
||||
if (!initData) {
|
||||
if (import.meta.env.DEV) {
|
||||
setSession(demoSession)
|
||||
applyDemoState()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1017,99 +1082,7 @@ function App() {
|
||||
}
|
||||
} catch {
|
||||
if (import.meta.env.DEV) {
|
||||
setDisplayNameDraft(demoSession.member.displayName)
|
||||
setSession(demoSession)
|
||||
setDashboard({
|
||||
period: '2026-03',
|
||||
currency: 'GEL',
|
||||
totalDueMajor: '1030.00',
|
||||
totalPaidMajor: '501.00',
|
||||
totalRemainingMajor: '529.00',
|
||||
rentSourceAmountMajor: '700.00',
|
||||
rentSourceCurrency: 'USD',
|
||||
rentDisplayAmountMajor: '1932.00',
|
||||
rentFxRateMicros: '2760000',
|
||||
rentFxEffectiveDate: '2026-03-17',
|
||||
members: [
|
||||
{
|
||||
memberId: 'demo-member',
|
||||
displayName: 'Demo Resident',
|
||||
rentShareMajor: '483.00',
|
||||
utilityShareMajor: '32.00',
|
||||
purchaseOffsetMajor: '-14.00',
|
||||
netDueMajor: '501.00',
|
||||
paidMajor: '501.00',
|
||||
remainingMajor: '0.00',
|
||||
explanations: ['Equal utility split', 'Shared purchase offset']
|
||||
},
|
||||
{
|
||||
memberId: 'member-2',
|
||||
displayName: 'Alice',
|
||||
rentShareMajor: '483.00',
|
||||
utilityShareMajor: '32.00',
|
||||
purchaseOffsetMajor: '14.00',
|
||||
netDueMajor: '529.00',
|
||||
paidMajor: '0.00',
|
||||
remainingMajor: '529.00',
|
||||
explanations: ['Equal utility split']
|
||||
}
|
||||
],
|
||||
ledger: [
|
||||
{
|
||||
id: 'purchase-1',
|
||||
kind: 'purchase',
|
||||
title: 'Soap',
|
||||
memberId: 'member-2',
|
||||
paymentKind: null,
|
||||
amountMajor: '30.00',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '30.00',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'Alice',
|
||||
occurredAt: '2026-03-12T11:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'utility-1',
|
||||
kind: 'utility',
|
||||
title: 'Electricity',
|
||||
memberId: null,
|
||||
paymentKind: null,
|
||||
amountMajor: '120.00',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '120.00',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'Alice',
|
||||
occurredAt: '2026-03-12T12:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'payment-1',
|
||||
kind: 'payment',
|
||||
title: 'rent',
|
||||
memberId: 'demo-member',
|
||||
paymentKind: 'rent',
|
||||
amountMajor: '501.00',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '501.00',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'Demo Resident',
|
||||
occurredAt: '2026-03-18T15:10:00.000Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
setPendingMembers([
|
||||
{
|
||||
telegramUserId: '555777',
|
||||
displayName: 'Mia',
|
||||
username: 'mia',
|
||||
languageCode: 'ru'
|
||||
}
|
||||
])
|
||||
applyDemoState()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1874,142 +1847,6 @@ function App() {
|
||||
}))
|
||||
}
|
||||
|
||||
function renderFinanceSummaryCards(data: MiniAppDashboard): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<article class="stat-card">
|
||||
<span>{copy().remainingLabel}</span>
|
||||
<strong>
|
||||
{data.totalRemainingMajor} {data.currency}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().shareRent}</span>
|
||||
<strong>
|
||||
{data.rentDisplayAmountMajor} {data.currency}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().shareUtilities}</span>
|
||||
<strong>
|
||||
{utilityTotalMajor()} {data.currency}
|
||||
</strong>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<span>{copy().purchasesTitle}</span>
|
||||
<strong>
|
||||
{purchaseTotalMajor()} {data.currency}
|
||||
</strong>
|
||||
</article>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderFinanceVisuals(data: MiniAppDashboard): JSX.Element {
|
||||
const purchaseChart = purchaseInvestmentChart()
|
||||
|
||||
return (
|
||||
<>
|
||||
<article class="balance-item balance-item--wide">
|
||||
<header>
|
||||
<strong>{copy().financeVisualsTitle}</strong>
|
||||
<span>
|
||||
{copy().membersCount}: {String(data.members.length)}
|
||||
</span>
|
||||
</header>
|
||||
<p>{copy().financeVisualsBody}</p>
|
||||
<div class="member-visual-list">
|
||||
{memberBalanceVisuals().map((item) => (
|
||||
<article class="member-visual-card">
|
||||
<header>
|
||||
<strong>{item.member.displayName}</strong>
|
||||
<span class={`balance-status ${memberRemainingClass(item.member)}`}>
|
||||
{item.member.remainingMajor} {data.currency}
|
||||
</span>
|
||||
</header>
|
||||
<div class="member-visual-bar">
|
||||
<div
|
||||
class="member-visual-bar__track"
|
||||
style={{ width: `${item.barWidthPercent}%` }}
|
||||
>
|
||||
{item.segments.map((segment) => (
|
||||
<span
|
||||
class={`member-visual-bar__segment member-visual-bar__segment--${segment.key}`}
|
||||
style={{ width: `${segment.widthPercent}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-visual-meta">
|
||||
{item.segments.map((segment) => (
|
||||
<span class={`member-visual-chip member-visual-chip--${segment.key}`}>
|
||||
{segment.label}: {segment.amountMajor} {data.currency}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="balance-item balance-item--wide">
|
||||
<header>
|
||||
<strong>{copy().purchaseInvestmentsTitle}</strong>
|
||||
<span>
|
||||
{copy().purchaseTotalLabel}: {purchaseChart.totalMajor} {data.currency}
|
||||
</span>
|
||||
</header>
|
||||
<p>{copy().purchaseInvestmentsBody}</p>
|
||||
{purchaseChart.slices.length === 0 ? (
|
||||
<p>{copy().purchaseInvestmentsEmpty}</p>
|
||||
) : (
|
||||
<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" />
|
||||
{purchaseChart.slices.map((slice) => (
|
||||
<circle
|
||||
class="purchase-chart__slice"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="42"
|
||||
stroke={slice.color}
|
||||
stroke-dasharray={slice.dasharray}
|
||||
stroke-dashoffset={slice.dashoffset}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div class="purchase-chart__center">
|
||||
<span>{copy().purchaseTotalLabel}</span>
|
||||
<strong>
|
||||
{purchaseChart.totalMajor} {data.currency}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="purchase-chart__legend">
|
||||
{purchaseChart.slices.map((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} {data.currency} · {copy().purchaseShareLabel}{' '}
|
||||
{slice.percentage}%
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPanel = () => {
|
||||
switch (activeNav()) {
|
||||
case 'balances':
|
||||
@@ -2063,8 +1900,35 @@ function App() {
|
||||
</div>
|
||||
</article>
|
||||
) : null}
|
||||
<div class="home-grid home-grid--summary">{renderFinanceSummaryCards(data)}</div>
|
||||
{renderFinanceVisuals(data)}
|
||||
<div class="home-grid home-grid--summary">
|
||||
<FinanceSummaryCards
|
||||
dashboard={data}
|
||||
utilityTotalMajor={utilityTotalMajor()}
|
||||
purchaseTotalMajor={purchaseTotalMajor()}
|
||||
labels={{
|
||||
remaining: copy().remainingLabel,
|
||||
rent: copy().shareRent,
|
||||
utilities: copy().shareUtilities,
|
||||
purchases: copy().purchasesTitle
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FinanceVisuals
|
||||
dashboard={data}
|
||||
memberVisuals={memberBalanceVisuals()}
|
||||
purchaseChart={purchaseInvestmentChart()}
|
||||
remainingClass={memberRemainingClass}
|
||||
labels={{
|
||||
financeVisualsTitle: copy().financeVisualsTitle,
|
||||
financeVisualsBody: copy().financeVisualsBody,
|
||||
membersCount: copy().membersCount,
|
||||
purchaseInvestmentsTitle: copy().purchaseInvestmentsTitle,
|
||||
purchaseInvestmentsBody: copy().purchaseInvestmentsBody,
|
||||
purchaseInvestmentsEmpty: copy().purchaseInvestmentsEmpty,
|
||||
purchaseTotalLabel: copy().purchaseTotalLabel,
|
||||
purchaseShareLabel: copy().purchaseShareLabel
|
||||
}}
|
||||
/>
|
||||
<article class="balance-item">
|
||||
<header>
|
||||
<strong>{copy().householdBalancesTitle}</strong>
|
||||
@@ -3739,7 +3603,19 @@ function App() {
|
||||
</article>
|
||||
</>
|
||||
}
|
||||
render={(data) => renderFinanceSummaryCards(data)}
|
||||
render={(data) => (
|
||||
<FinanceSummaryCards
|
||||
dashboard={data}
|
||||
utilityTotalMajor={utilityTotalMajor()}
|
||||
purchaseTotalMajor={purchaseTotalMajor()}
|
||||
labels={{
|
||||
remaining: copy().remainingLabel,
|
||||
rent: copy().shareRent,
|
||||
utilities: copy().shareUtilities,
|
||||
purchases: copy().purchasesTitle
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{readySession()?.member.isAdmin ? (
|
||||
<article class="stat-card">
|
||||
@@ -3814,7 +3690,24 @@ function App() {
|
||||
<ShowDashboard
|
||||
dashboard={dashboard()}
|
||||
fallback={null}
|
||||
render={(data) => renderFinanceVisuals(data)}
|
||||
render={(data) => (
|
||||
<FinanceVisuals
|
||||
dashboard={data}
|
||||
memberVisuals={memberBalanceVisuals()}
|
||||
purchaseChart={purchaseInvestmentChart()}
|
||||
remainingClass={memberRemainingClass}
|
||||
labels={{
|
||||
financeVisualsTitle: copy().financeVisualsTitle,
|
||||
financeVisualsBody: copy().financeVisualsBody,
|
||||
membersCount: copy().membersCount,
|
||||
purchaseInvestmentsTitle: copy().purchaseInvestmentsTitle,
|
||||
purchaseInvestmentsBody: copy().purchaseInvestmentsBody,
|
||||
purchaseInvestmentsEmpty: copy().purchaseInvestmentsEmpty,
|
||||
purchaseTotalLabel: copy().purchaseTotalLabel,
|
||||
purchaseShareLabel: copy().purchaseShareLabel
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<article class="balance-item balance-item--wide">
|
||||
@@ -3856,66 +3749,46 @@ function App() {
|
||||
<div class="shell__backdrop shell__backdrop--top" />
|
||||
<div class="shell__backdrop shell__backdrop--bottom" />
|
||||
|
||||
<section class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">{copy().appSubtitle}</p>
|
||||
<h1>{copy().appTitle}</h1>
|
||||
</div>
|
||||
|
||||
<label class="locale-switch">
|
||||
<span>{copy().language}</span>
|
||||
<div class="locale-switch__buttons">
|
||||
<button
|
||||
classList={{ 'is-active': locale() === 'en' }}
|
||||
type="button"
|
||||
disabled={savingMemberLocale()}
|
||||
onClick={() => void handleMemberLocaleChange('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
classList={{ 'is-active': locale() === 'ru' }}
|
||||
type="button"
|
||||
disabled={savingMemberLocale()}
|
||||
onClick={() => void handleMemberLocaleChange('ru')}
|
||||
>
|
||||
RU
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
<TopBar
|
||||
subtitle={copy().appSubtitle}
|
||||
title={copy().appTitle}
|
||||
languageLabel={copy().language}
|
||||
locale={locale()}
|
||||
saving={savingMemberLocale()}
|
||||
onChange={(nextLocale) => void handleMemberLocaleChange(nextLocale)}
|
||||
/>
|
||||
|
||||
<Switch>
|
||||
<Match when={session().status === 'loading'}>
|
||||
<section class="hero-card">
|
||||
<span class="pill">{copy().loadingBadge}</span>
|
||||
<h2>{copy().loadingTitle}</h2>
|
||||
<p>{copy().loadingBody}</p>
|
||||
</section>
|
||||
<HeroBanner
|
||||
badges={[copy().loadingBadge]}
|
||||
title={copy().loadingTitle}
|
||||
body={copy().loadingBody}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match when={session().status === 'blocked'}>
|
||||
<section class="hero-card">
|
||||
<span class="pill">{copy().loadingBadge}</span>
|
||||
<h2>
|
||||
{blockedSession()?.reason === 'telegram_only'
|
||||
<HeroBanner
|
||||
badges={[copy().loadingBadge]}
|
||||
title={
|
||||
blockedSession()?.reason === 'telegram_only'
|
||||
? copy().telegramOnlyTitle
|
||||
: copy().unexpectedErrorTitle}
|
||||
</h2>
|
||||
<p>
|
||||
{blockedSession()?.reason === 'telegram_only'
|
||||
: copy().unexpectedErrorTitle
|
||||
}
|
||||
body={
|
||||
blockedSession()?.reason === 'telegram_only'
|
||||
? copy().telegramOnlyBody
|
||||
: copy().unexpectedErrorBody}
|
||||
</p>
|
||||
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
|
||||
{copy().reload}
|
||||
</button>
|
||||
</section>
|
||||
: copy().unexpectedErrorBody
|
||||
}
|
||||
action={{ label: copy().reload, onClick: () => window.location.reload() }}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match when={session().status === 'onboarding'}>
|
||||
<section class="hero-card">
|
||||
<span class="pill">{copy().loadingBadge}</span>
|
||||
<div class="hero-card__meta">
|
||||
<span class="pill">{copy().loadingBadge}</span>
|
||||
</div>
|
||||
<h2>
|
||||
{onboardingSession()?.mode === 'pending'
|
||||
? copy().pendingTitle
|
||||
@@ -3938,18 +3811,13 @@ function App() {
|
||||
</p>
|
||||
<div class="nav-grid">
|
||||
{onboardingSession()?.mode === 'join_required' ? (
|
||||
<button
|
||||
class="ghost-button"
|
||||
type="button"
|
||||
disabled={joining()}
|
||||
onClick={handleJoinHousehold}
|
||||
>
|
||||
<Button variant="ghost" disabled={joining()} onClick={handleJoinHousehold}>
|
||||
{joining() ? copy().joining : copy().joinAction}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
{joinDeepLink() ? (
|
||||
<a
|
||||
class="ghost-button"
|
||||
class="ui-button ui-button--ghost"
|
||||
href={joinDeepLink() ?? '#'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -3957,83 +3825,60 @@ function App() {
|
||||
{copy().botLinkAction}
|
||||
</a>
|
||||
) : null}
|
||||
<button class="ghost-button" type="button" onClick={() => window.location.reload()}>
|
||||
<Button variant="ghost" onClick={() => window.location.reload()}>
|
||||
{copy().reload}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</Match>
|
||||
|
||||
<Match when={session().status === 'ready'}>
|
||||
<section class="hero-card">
|
||||
<div class="hero-card__meta">
|
||||
<span class="pill">
|
||||
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
|
||||
</span>
|
||||
<span class="pill pill--muted">
|
||||
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
|
||||
</span>
|
||||
<span class="pill pill--muted">
|
||||
{readySession()?.member.status
|
||||
? memberStatusLabel(readySession()!.member.status)
|
||||
: copy().memberStatusActive}
|
||||
</span>
|
||||
</div>
|
||||
<HeroBanner
|
||||
badges={[
|
||||
readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge,
|
||||
readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag,
|
||||
readySession()?.member.status
|
||||
? memberStatusLabel(readySession()!.member.status)
|
||||
: copy().memberStatusActive
|
||||
]}
|
||||
title={`${copy().welcome}, ${readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}`}
|
||||
body={copy().overviewBody}
|
||||
action={
|
||||
readySession()?.mode === 'live'
|
||||
? {
|
||||
label: copy().manageProfileAction,
|
||||
onClick: () => setProfileEditorOpen(true)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<h2>
|
||||
{copy().welcome},{' '}
|
||||
{readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}
|
||||
</h2>
|
||||
<p>{copy().overviewBody}</p>
|
||||
<Show when={readySession()?.mode === 'live'}>
|
||||
<div class="panel-toolbar">
|
||||
<Button variant="secondary" onClick={() => setProfileEditorOpen(true)}>
|
||||
{copy().manageProfileAction}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<nav class="nav-grid">
|
||||
{(
|
||||
<NavigationTabs
|
||||
items={
|
||||
[
|
||||
['home', copy().home],
|
||||
['balances', copy().balances],
|
||||
['ledger', copy().ledger],
|
||||
['house', copy().house]
|
||||
{ key: 'home', label: copy().home },
|
||||
{ key: 'balances', label: copy().balances },
|
||||
{ key: 'ledger', label: copy().ledger },
|
||||
{ key: 'house', label: copy().house }
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
classList={{ 'is-active': activeNav() === key }}
|
||||
type="button"
|
||||
onClick={() => setActiveNav(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
}
|
||||
active={activeNav()}
|
||||
onChange={setActiveNav}
|
||||
/>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="balance-item balance-item--accent profile-card">
|
||||
<header>
|
||||
<strong>{readySession()?.member.displayName}</strong>
|
||||
<span>{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}</span>
|
||||
</header>
|
||||
<p>
|
||||
{copy().memberStatusSummary.replace(
|
||||
'{status}',
|
||||
readySession()?.member.status
|
||||
? memberStatusLabel(readySession()!.member.status)
|
||||
: copy().memberStatusActive
|
||||
)}
|
||||
</p>
|
||||
<div class="ledger-compact-card__meta">
|
||||
<span class="mini-chip">
|
||||
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
|
||||
</span>
|
||||
<span class="mini-chip mini-chip--muted">{locale().toUpperCase()}</span>
|
||||
</div>
|
||||
</article>
|
||||
<ProfileCard
|
||||
displayName={readySession()?.member.displayName ?? ''}
|
||||
roleLabel={readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
|
||||
statusSummary={copy().memberStatusSummary.replace(
|
||||
'{status}',
|
||||
readySession()?.member.status
|
||||
? memberStatusLabel(readySession()!.member.status)
|
||||
: copy().memberStatusActive
|
||||
)}
|
||||
modeBadge={readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
|
||||
localeBadge={locale().toUpperCase()}
|
||||
/>
|
||||
<div class="content-stack">{renderPanel()}</div>
|
||||
</section>
|
||||
<Modal
|
||||
|
||||
10
apps/miniapp/src/app/query-client.ts
Normal file
10
apps/miniapp/src/app/query-client.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { QueryClient } from '@tanstack/solid-query'
|
||||
|
||||
export const miniAppQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
285
apps/miniapp/src/demo/miniapp-demo.ts
Normal file
285
apps/miniapp/src/demo/miniapp-demo.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
MiniAppAdminCycleState,
|
||||
MiniAppAdminSettingsPayload,
|
||||
MiniAppDashboard,
|
||||
MiniAppPendingMember,
|
||||
MiniAppSession
|
||||
} from '../miniapp-api'
|
||||
|
||||
export const demoMember: NonNullable<MiniAppSession['member']> = {
|
||||
id: 'demo-member',
|
||||
householdId: 'demo-household',
|
||||
displayName: 'Stas',
|
||||
status: 'active',
|
||||
isAdmin: true,
|
||||
preferredLocale: 'en',
|
||||
householdDefaultLocale: 'en'
|
||||
}
|
||||
|
||||
export const demoTelegramUser: NonNullable<MiniAppSession['telegramUser']> = {
|
||||
firstName: 'Stas',
|
||||
username: 'stas_demo',
|
||||
languageCode: 'en'
|
||||
}
|
||||
|
||||
export const demoDashboard: MiniAppDashboard = {
|
||||
period: '2026-03',
|
||||
currency: 'GEL',
|
||||
totalDueMajor: '2410.00',
|
||||
totalPaidMajor: '650.00',
|
||||
totalRemainingMajor: '1760.00',
|
||||
rentSourceAmountMajor: '875.00',
|
||||
rentSourceCurrency: 'USD',
|
||||
rentDisplayAmountMajor: '2415.00',
|
||||
rentFxRateMicros: '2760000',
|
||||
rentFxEffectiveDate: '2026-03-17',
|
||||
members: [
|
||||
{
|
||||
memberId: 'demo-member',
|
||||
displayName: 'Stas',
|
||||
rentShareMajor: '603.75',
|
||||
utilityShareMajor: '78.00',
|
||||
purchaseOffsetMajor: '-66.00',
|
||||
netDueMajor: '615.75',
|
||||
paidMajor: '615.75',
|
||||
remainingMajor: '0.00',
|
||||
explanations: ['Weighted rent share', 'Custom purchase split credit']
|
||||
},
|
||||
{
|
||||
memberId: 'member-chorb',
|
||||
displayName: 'Chorbanaut',
|
||||
rentShareMajor: '603.75',
|
||||
utilityShareMajor: '78.00',
|
||||
purchaseOffsetMajor: '12.00',
|
||||
netDueMajor: '693.75',
|
||||
paidMajor: '0.00',
|
||||
remainingMajor: '693.75',
|
||||
explanations: ['Standard resident share']
|
||||
},
|
||||
{
|
||||
memberId: 'member-el',
|
||||
displayName: 'El',
|
||||
rentShareMajor: '1207.50',
|
||||
utilityShareMajor: '0.00',
|
||||
purchaseOffsetMajor: '54.00',
|
||||
netDueMajor: '1261.50',
|
||||
paidMajor: '34.25',
|
||||
remainingMajor: '1227.25',
|
||||
explanations: ['Away policy applied to utilities']
|
||||
}
|
||||
],
|
||||
ledger: [
|
||||
{
|
||||
id: 'purchase-1',
|
||||
kind: 'purchase',
|
||||
title: 'Bought kitchen towels',
|
||||
memberId: 'demo-member',
|
||||
paymentKind: null,
|
||||
amountMajor: '24.00',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '24.00',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'Stas',
|
||||
occurredAt: '2026-03-04T11:00:00.000Z',
|
||||
purchaseSplitMode: 'equal',
|
||||
purchaseParticipants: [
|
||||
{ memberId: 'demo-member', included: true, shareAmountMajor: null },
|
||||
{ memberId: 'member-chorb', included: true, shareAmountMajor: null },
|
||||
{ memberId: 'member-el', included: false, shareAmountMajor: null }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'purchase-2',
|
||||
kind: 'purchase',
|
||||
title: 'Electric kettle',
|
||||
memberId: 'member-chorb',
|
||||
paymentKind: null,
|
||||
amountMajor: '96.00',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '96.00',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'Chorbanaut',
|
||||
occurredAt: '2026-03-08T16:20:00.000Z',
|
||||
purchaseSplitMode: 'custom_amounts',
|
||||
purchaseParticipants: [
|
||||
{ memberId: 'demo-member', included: true, shareAmountMajor: '42.00' },
|
||||
{ memberId: 'member-chorb', included: true, shareAmountMajor: '24.00' },
|
||||
{ memberId: 'member-el', included: true, shareAmountMajor: '30.00' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'utility-1',
|
||||
kind: 'utility',
|
||||
title: 'Electricity',
|
||||
memberId: null,
|
||||
paymentKind: null,
|
||||
amountMajor: '154.00',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '154.00',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'Stas',
|
||||
occurredAt: '2026-03-09T12:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'utility-2',
|
||||
kind: 'utility',
|
||||
title: 'Internet',
|
||||
memberId: null,
|
||||
paymentKind: null,
|
||||
amountMajor: '80.00',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '80.00',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'Stas',
|
||||
occurredAt: '2026-03-10T10:30:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'payment-1',
|
||||
kind: 'payment',
|
||||
title: 'rent',
|
||||
memberId: 'demo-member',
|
||||
paymentKind: 'rent',
|
||||
amountMajor: '615.75',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '615.75',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'Stas',
|
||||
occurredAt: '2026-03-11T18:10:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'payment-2',
|
||||
kind: 'payment',
|
||||
title: 'utilities',
|
||||
memberId: 'member-el',
|
||||
paymentKind: 'utilities',
|
||||
amountMajor: '34.25',
|
||||
currency: 'GEL',
|
||||
displayAmountMajor: '34.25',
|
||||
displayCurrency: 'GEL',
|
||||
fxRateMicros: null,
|
||||
fxEffectiveDate: null,
|
||||
actorDisplayName: 'El',
|
||||
occurredAt: '2026-03-13T09:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const demoPendingMembers: readonly MiniAppPendingMember[] = [
|
||||
{
|
||||
telegramUserId: '555777',
|
||||
displayName: 'Mia',
|
||||
username: 'mia',
|
||||
languageCode: 'ru'
|
||||
},
|
||||
{
|
||||
telegramUserId: '777999',
|
||||
displayName: 'Dima',
|
||||
username: 'dima',
|
||||
languageCode: 'en'
|
||||
}
|
||||
]
|
||||
|
||||
export const demoAdminSettings: MiniAppAdminSettingsPayload = {
|
||||
settings: {
|
||||
householdId: 'demo-household',
|
||||
settlementCurrency: 'GEL',
|
||||
paymentBalanceAdjustmentPolicy: 'utilities',
|
||||
rentAmountMinor: '241500',
|
||||
rentCurrency: 'USD',
|
||||
rentDueDay: 20,
|
||||
rentWarningDay: 17,
|
||||
utilitiesDueDay: 4,
|
||||
utilitiesReminderDay: 3,
|
||||
timezone: 'Asia/Tbilisi'
|
||||
},
|
||||
topics: [
|
||||
{ role: 'purchase', telegramThreadId: '101', topicName: 'Purchases' },
|
||||
{ role: 'feedback', telegramThreadId: '102', topicName: 'Anonymous feedback' },
|
||||
{ role: 'reminders', telegramThreadId: '103', topicName: 'Reminders' },
|
||||
{ role: 'payments', telegramThreadId: '104', topicName: 'Payments' }
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
id: 'cat-electricity',
|
||||
householdId: 'demo-household',
|
||||
slug: 'electricity',
|
||||
name: 'Electricity',
|
||||
sortOrder: 0,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: 'cat-internet',
|
||||
householdId: 'demo-household',
|
||||
slug: 'internet',
|
||||
name: 'Internet',
|
||||
sortOrder: 1,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
id: 'cat-gas',
|
||||
householdId: 'demo-household',
|
||||
slug: 'gas',
|
||||
name: 'Gas',
|
||||
sortOrder: 2,
|
||||
isActive: false
|
||||
}
|
||||
],
|
||||
members: [
|
||||
{ id: 'demo-member', displayName: 'Stas', status: 'active', rentShareWeight: 1, isAdmin: true },
|
||||
{
|
||||
id: 'member-chorb',
|
||||
displayName: 'Chorbanaut',
|
||||
status: 'active',
|
||||
rentShareWeight: 1,
|
||||
isAdmin: false
|
||||
},
|
||||
{ id: 'member-el', displayName: 'El', status: 'away', rentShareWeight: 2, isAdmin: false }
|
||||
],
|
||||
memberAbsencePolicies: [
|
||||
{
|
||||
memberId: 'member-el',
|
||||
effectiveFromPeriod: '2026-03',
|
||||
policy: 'away_rent_only'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const demoCycleState: MiniAppAdminCycleState = {
|
||||
cycle: {
|
||||
id: 'cycle-demo-2026-03',
|
||||
period: '2026-03',
|
||||
currency: 'GEL'
|
||||
},
|
||||
rentRule: {
|
||||
amountMinor: '241500',
|
||||
currency: 'USD'
|
||||
},
|
||||
utilityBills: [
|
||||
{
|
||||
id: 'utility-bill-1',
|
||||
billName: 'Electricity',
|
||||
amountMinor: '15400',
|
||||
currency: 'GEL',
|
||||
createdByMemberId: 'demo-member',
|
||||
createdAt: '2026-03-09T12:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'utility-bill-2',
|
||||
billName: 'Internet',
|
||||
amountMinor: '8000',
|
||||
currency: 'GEL',
|
||||
createdByMemberId: 'demo-member',
|
||||
createdAt: '2026-03-10T10:30:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/* @refresh reload */
|
||||
import { QueryClientProvider } from '@tanstack/solid-query'
|
||||
import { render } from 'solid-js/web'
|
||||
|
||||
import { miniAppQueryClient } from './app/query-client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
@@ -10,4 +12,11 @@ if (!root) {
|
||||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
render(() => <App />, root)
|
||||
render(
|
||||
() => (
|
||||
<QueryClientProvider client={miniAppQueryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
root
|
||||
)
|
||||
|
||||
5
apps/miniapp/src/lib/cn.ts
Normal file
5
apps/miniapp/src/lib/cn.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs)
|
||||
}
|
||||
45
bun.lock
45
bun.lock
@@ -30,8 +30,13 @@
|
||||
"apps/miniapp": {
|
||||
"name": "@household/miniapp",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@tanstack/solid-query": "5.90.23",
|
||||
"@twa-dev/sdk": "8.0.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"solid-js": "^1.9.9",
|
||||
"zod": "4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
@@ -141,6 +146,8 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||
@@ -199,6 +206,12 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@grammyjs/types": ["@grammyjs/types@3.25.0", "", {}, "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg=="],
|
||||
|
||||
"@household/adapters-db": ["@household/adapters-db@workspace:packages/adapters-db"],
|
||||
@@ -223,6 +236,10 @@
|
||||
|
||||
"@household/scripts": ["@household/scripts@workspace:scripts"],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.12.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
@@ -237,6 +254,10 @@
|
||||
|
||||
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
|
||||
|
||||
"@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="],
|
||||
|
||||
"@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="],
|
||||
|
||||
"@nothing-but/utils": ["@nothing-but/utils@0.17.0", "", {}, "sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.51.0", "", { "os": "android", "cpu": "arm" }, "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw=="],
|
||||
@@ -341,8 +362,14 @@
|
||||
|
||||
"@solid-primitives/keyboard": ["@solid-primitives/keyboard@1.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-sav+l+PL+74z3yaftVs7qd8c2SXkqzuxPOVibUe5wYMt+U5Hxp3V3XCPgBPN2I6cANjvoFtz0NiU8uHVLdi9FQ=="],
|
||||
|
||||
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
|
||||
|
||||
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
|
||||
|
||||
"@solid-primitives/media": ["@solid-primitives/media@2.3.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-LX9fB5WDaK87FMDtUB1qokBOfT2et9Uobv/zZaKLH9caFSz4+P70MBKEIBHcZQy+9MV5M2XvGYLTbLskjkzMjA=="],
|
||||
|
||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="],
|
||||
|
||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
|
||||
|
||||
"@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.5", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.5", "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/static-store": "^0.1.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw=="],
|
||||
@@ -355,8 +382,12 @@
|
||||
|
||||
"@solid-primitives/styles": ["@solid-primitives/styles@0.1.3", "", { "dependencies": { "@solid-primitives/rootless": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-7YdA21prMeCX+oOF/1RAn02+cGz/pG4dyPWtHBC2H8aZvnC7IfThBt80mP+TioejrdfE7Lc54Uh18f7Pig+gRQ=="],
|
||||
|
||||
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
|
||||
|
||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
||||
|
||||
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
|
||||
@@ -389,6 +420,10 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
||||
|
||||
"@tanstack/solid-query": ["@tanstack/solid-query@5.90.23", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-pbZc4+Kgm7ktzIuu01R3KOWfazQKgNp4AZvW0RSvv+sNMpYoileUDAkXEcjDJe6RJmb3fVvTR4LlcSL5pxDElQ=="],
|
||||
|
||||
"@twa-dev/sdk": ["@twa-dev/sdk@8.0.2", "", { "dependencies": { "@twa-dev/types": "^8.0.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Pp5GxnxP2blboVZFiM9aWjs4cb8IpW3x2jP3kLOMvIqy0jzNUTuFHkwHtx+zEvh/UcF2F+wmS8G6ebIA0XPXcg=="],
|
||||
|
||||
"@twa-dev/types": ["@twa-dev/types@8.0.2", "", {}, "sha512-ICQ6n4NaUPPzV3/GzflVQS6Nnu5QX2vr9OlOG8ZkFf3rSJXzRKazrLAbZlVhCPPWkIW3MMuELPsE6tByrA49qA=="],
|
||||
@@ -457,6 +492,10 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
@@ -681,6 +720,10 @@
|
||||
|
||||
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
|
||||
|
||||
"solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
|
||||
|
||||
"solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="],
|
||||
|
||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
@@ -711,6 +754,8 @@
|
||||
|
||||
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
Reference in New Issue
Block a user