{{ currentSlideData.title }}
+{{ currentSlideData.text }}
+{{ meta.name }} threw during render:
+{{ renderError }}
+
+ It likely needs a data shape (e.g. a budget/party/wallet
+ object) not yet provided in the playground registry.
+
Modal body content goes here.
++ A Vue 3 + Nuxt component library for building personal-finance and + budgeting apps — accounts, transactions, budgets, charts, and the + surrounding UI, ready to drop in. +
++ {{ total }} components across {{ categories.length }} areas. Every one is + live-previewable from the sidebar — click any name to see it rendered + with realistic sample data. +
+# npm
+npm install @trakli/ui-kit
+
+# pnpm
+pnpm add @trakli/ui-kit
+
+ The kit ships as a Nuxt layer, so it brings its own
+ components, composables, and design tokens. Add it to your project's
+ extends:
+
// nuxt.config.ts
+export default defineNuxtConfig({
+ extends: ['@trakli/ui-kit']
+});
+
+ Components are auto-imported — no import statements needed.
+ Most accept a plain object (e.g. a wallet, budget,
+ or party) plus an optional labels object for
+ copy:
+
<template>
+ <TButton text="Get started" variant="primary" />
+ <TContainer>
+ <TCard title="Balance">$1,240.00</TCard>
+ </TContainer>
+</template>
+
+ Styling is driven by CSS custom properties and SCSS tokens
+ ($primary, $border-light, …). Override the
+ tokens in your own stylesheet to re-skin the kit — no component edits
+ required.
+
Pick a component from the sidebar to preview it →
+Expense: {{ isExpenseSelected }}, Income: {{ isIncomeSelected }}, Transfer: {{ isTransferSelected }}
+Container, Stack, Grid, and Divider components for layout.
+ +Max-width centered container
+Content inside a TContainer is centered and has a maximum width.
+Vertical or horizontal flex stack with consistent spacing
+ +Item 1
Item 2
Item 3
Item A
Item B
Item C
Responsive grid layout
+ +Column 1
Column 2
Column 3
Column 4
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Auto 1
Auto 2
Auto 3
Auto 4
Visual divider line
+Content above divider
+Content below divider
+Content with large spacing
+Button primitive with multiple variants and states.
+ +Clicked {{ clickCount }} times
+Card surface primitive for containing content.
+ +This is a basic card with some content inside.
+No padding (custom padding applied)
+Small padding
+Default padding
+Large padding
+No shadow
+Elevation 1
+Elevation 2
+Elevation 3
+Click me! I have hover effects.
+✓ Card clicked!
+Form field wrapper with label, hints, and error display.
+ +Hello, {{ name }}!
+Modal dialog primitive with header, body, and footer sections.
+ +This is a basic modal with header, body, and footer sections.
+This is a larger modal for more complex content.
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+{{ savedData }}
+ | Date | Type | Party | Amount | Category | Actions |
|---|
Tab components with multiple variants and features.
+ +This is the content for the first tab.
+This is the content for the second tab.
+This is the content for the third tab.
+Underline style content 1
+Underline style content 2
+Underline style content 3
+Indicator style content 1
+Indicator style content 2
+Indicator style content 3
+Welcome home!
+Your profile information
+Adjust your settings
+All items ({{ countTabs[0].count }})
+Active items ({{ countTabs[1].count }})
+Completed items ({{ countTabs[2].count }})
+Use arrow keys to navigate between tabs
+Use ← → arrow keys to navigate
+Press Home to go to first tab
+Press End to go to last tab
+Selected: {{ intent }}
++ Full-screen spreadsheet is teleported to body — preview below renders the overlay. +
+Utility primitives for common UI patterns.
+ +Loading placeholder animations
+Search input with icon
+Searching for: {{ searchQuery }}
+Toggle between light and dark themes
+Click to toggle theme
+Dropdown theme selector
+Animated typing indicator
+Toggle between list and grid views
+Current view: {{ viewMode }}
+Animated hamburger menu icon
+Menu is {{ menuOpen ? 'open' : 'closed' }}
+Welcome to Trakli — your monthly summary is ready.
Expense: {{ isExpenseSelected }}, Income: {{ isIncomeSelected }}, Transfer: {{ isTransferSelected }}
+| Date | Type | Party | Amount | Category | Actions |
|---|
Selected: {{ intent }}
++ Full-screen spreadsheet is teleported to body — preview below renders the overlay. +
+{{ formatPeriod(budget) }}
++ {{ labels.noItemsToChoose }} +
++ {{ labels.eachTile }} +
+{{ format(totals.net) }}
+ ++ {{ labels.vs }} {{ format(totals.prevNet) }} + +
+{{ format(totals.income) }}
+ + {{ totals.incomeDelta > 0 ? '+' : '' }}{{ totals.incomeDelta }}% + ++ {{ labels.vs }} {{ format(totals.prevIncome) }} +
+{{ format(totals.expense) }}
+ + {{ totals.expenseDelta > 0 ? '+' : '' }}{{ totals.expenseDelta }}% + ++ {{ labels.vs }} {{ format(totals.prevExpense) }} +
+{{ (totals.savingsRate * 100).toFixed(0) }}%
+ + {{ totals.savingsRateDelta > 0 ? '+' : '' }}{{ totals.savingsRateDelta }} pts + ++ {{ labels.vs }} {{ (totals.prevSavingsRate * 100).toFixed(0) }}% +
+{{ data[hover].label }}
++ {{ labels.income }}: + {{ formatter(data[hover].income, currency) }} +
++ {{ labels.expense }}: + {{ formatter(data[hover].expense, currency) }} +
++ {{ labels.net }}: {{ formatter(data[hover].net, currency) }} +
+{{ labels.noExpenseData }}
+{{ centerLabel }}
+{{ formatter(total, currency) }}
++ {{ fill(labels.periodOverview, { n: bucket.count }) }} +
+{{ labels.noTransactionsMatch }}
+{{ labels.noTransactionsMatch }}
+ +{{ subtitle }}
+{{ labels.empty }}
+{{ current.sub }}
+{{ title }}
+{{ text }}
+{{ title }}
+ +{{ pretty }}
+ {{ title }}
+{{ side.heading }}
+{{ labels.tryOneOfThese }}
+{{ labels.heroSubtitle }}
+{{ title }}
+
+
{{ title }}
+{{ title }}
+{{ prompt }}
+ +| + {{ formatKey(key) }} + | +
|---|
| + {{ formatValue(value) }} + | +
{{ JSON.stringify(result.rows, null, 2) }}
+ {{ title }}
+ + +| {{ formatKey(col) }} | +
|---|
| {{ formatValue(row[col]) }} | +
{{ title }}
+{{ item.description }}
+{{ isEditMode ? "Edit mode" : "View mode" }}
| + {{ col.label }} + | +{{ actionLabel }} | +
|---|---|
|
+
+
+
+
+
+ {{ col.render(entity[col.key], entity) }}
+
+
+ {{ getCellValue(entity, col.key) }}
+
+ |
+
+
+
+
+
+ |
+
{{ data[hover].label }}
+{{ formatter(data[hover].cumulative, currency) }}
+{{ formatLong(data[hover].date) }}
+{{ formatter(data[hover].expense, currency) }}
+ +{{ labels.subtitle }}
++ We've created some starter items for you, but let's personalize them and add your real + financial data to get the most out of Trakli. +
+{{ step.description }}
++ {{ onboarding?.description || card?.description }} +
++ {{ card?.description || integration.description }} +
+
'
+ }
+ });
+ cy.get('.empty-icon img').should('have.attr', 'src', 'custom-empty.png');
+ cy.get('.empty-icon svg').should('not.exist');
+ });
+
+ it('emits create when the button is clicked', () => {
+ const onCreate = cy.stub().as('create');
+ cy.mount(EmptyState, {
+ props: { title: 't', subtitle: 's', buttonLabel: 'Add', onCreate }
+ });
+ cy.get('.add-entity-btn').click();
+ cy.get('@create').should('have.been.called');
+ });
+});
diff --git a/components/EmptyState.stories.js b/components/EmptyState.stories.js
new file mode 100644
index 0000000..62b0599
--- /dev/null
+++ b/components/EmptyState.stories.js
@@ -0,0 +1,50 @@
+import EmptyState from './EmptyState.vue';
+
+export default {
+ title: 'Primitives/EmptyState',
+ component: EmptyState,
+ argTypes: {
+ title: { control: 'text' },
+ subtitle: { control: 'text' },
+ buttonLabel: { control: 'text' },
+ iconAlt: { control: 'text' }
+ }
+};
+
+const Template = (args) => ({
+ components: { EmptyState },
+ setup() {
+ return { args };
+ },
+ template: `
+ + {{ subtitle }} +
+ +{{ signedMoney(position.net_worth_delta) }}
+ +{{ labels.heroSub }}
+{{ money(position.total_net_worth) }}
+ {{ labels.subtitle }} +
++ {{ fill(labels.partialNote, { currencies: unconvertedCurrencies.join(', ') }) }} +
+ +
+
{{ labels.noDataForPeriod }}
+{{ ratio.formatted }}
+{{ labels.totalIn }}
+{{ formatter(flow.totalIn, currency) }}
+{{ labels.totalOut }}
+{{ formatter(flow.totalOut, currency) }}
+{{ flow.savings >= 0 ? labels.saved : labels.shortfall }}
+{{ formatter(Math.abs(flow.savings), currency) }}
++ {{ labels.pricedAutomatically }} ({{ form.external_ref }}) +
+{{ currentMessage }}
+{{ fileName }}
+ +{{ fill(labels.youAreAboutToImport, { count: acceptedCount }) }}
+ ++ {{ labels.someTransactionsReference }} +
+ + + + + + + ++ {{ labels.uncheckedItemsSkipped }} +
+ ++ {{ labels.walletGapWarning }} +
+{{ labels.subtitle }}
+ +{{ labels.dropzone }}
+{{ labels.supportedFormats }}
+{{ concept.description }}
+{{ tip.text }}
+
'
+ }
+ });
+ cy.get('.logo img').should('have.attr', 'src', 'custom-logo.png');
+ cy.get('.logo svg').should('not.exist');
+ });
+});
diff --git a/components/Logo.stories.js b/components/Logo.stories.js
new file mode 100644
index 0000000..b85d9de
--- /dev/null
+++ b/components/Logo.stories.js
@@ -0,0 +1,27 @@
+import Logo from './Logo.vue';
+
+export default {
+ title: 'Primitives/Logo',
+ component: Logo,
+ argTypes: {
+ alt: { control: 'text' },
+ size: { control: { type: 'select', options: ['small', 'medium', 'large'] } }
+ }
+};
+
+const Template = (args) => ({
+ components: { Logo },
+ setup() {
+ return { args };
+ },
+ template: `+ {{ formatter(notable.biggestExpense.amount, currency) }} +
+ +{{ formatDate(notable.biggestExpense.date) }}
+ +{{ labels.noExpenses }}
++ {{ notable.biggestCategorySwing.name }} +
+ +
+
{{ labels.noComparableChanges }}
+{{ notable.firstTimePayees.count }}
+ + + +{{ labels.allFamiliarNames }}
++ {{ notable.longestNoSpendStreak.days }} + {{ labels.days }} +
+ +{{ labels.everyDayHadSpending }}
+{{ labels.loading }}
+{{ labels.noNotifications }}
+{{ notification.title }}
+{{ truncateText(notification.body, 60) }}
+{{ formatTime(notification.created_at) }}
+{{ config.subtitle }}
+ +Preview content
', + sending: false, + imageUploading: false +} }; +export const Empty = { args: { + users: [], + previewHtml: '', + sending: false +} }; +export const Alternative = { args: { + users: [{ id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' }], + previewHtml: 'Preview content
', + sending: true, + imageUploading: false +} }; diff --git a/components/OutreachComposer.vue b/components/OutreachComposer.vue new file mode 100644 index 0000000..b89888f --- /dev/null +++ b/components/OutreachComposer.vue @@ -0,0 +1,405 @@ + +{{ labels.noMatches }}
+Hi Alice, welcome to Trakli!
', + labels, + }, +}; + +export const Empty = { + args: { + subject: '', + message: '', + previewHtml: '', + labels, + }, +}; + +export const Interactive = { + args: { + subject: 'Monthly Update {{name}}', + message: 'Dear {{first_name}} {{last_name}},\n\nYour balance is ready.', + previewHtml: 'Dear Alice Johnson,
Your balance is ready.
', + labels, + }, +}; diff --git a/components/OutreachComposerMessage.vue b/components/OutreachComposerMessage.vue new file mode 100644 index 0000000..2417447 --- /dev/null +++ b/components/OutreachComposerMessage.vue @@ -0,0 +1,290 @@ + + + + + + + diff --git a/components/PartiesForm.stories.js b/components/PartiesForm.stories.js new file mode 100644 index 0000000..34d0e5a --- /dev/null +++ b/components/PartiesForm.stories.js @@ -0,0 +1,22 @@ +import PartiesForm from './PartiesForm.vue'; + +export default { + title: 'Components/PartiesForm', + component: PartiesForm, + tags: ['autodocs'], +}; + +export const Default = { args: { + editingItem: null, + apiError: '', + isSubmitting: false +} }; +export const Empty = { args: { + editingItem: null, + isSubmitting: false +} }; +export const Alternative = { args: { + editingItem: null, + apiError: '', + isSubmitting: true +} }; diff --git a/components/PartiesForm.vue b/components/PartiesForm.vue new file mode 100644 index 0000000..cb5e5b9 --- /dev/null +++ b/components/PartiesForm.vue @@ -0,0 +1,297 @@ + +{{ stat.value }}
+ {{ stat.sub }} +{{ party.description }}
+ +{{ formatter(party.receivedAmount || 0, currency) }}
{{ formatter(party.spentAmount || 0, currency) }}
{{ formatter(netValue, currency) }}
{{ labels.selectParty }}
++ {{ labels.pickParty }} +
++ {{ labels.confirmPrompt }} +
+ +{{ labels.noTransactionsYet }}
+{{ labels.text }}
++ {{ labels.empty }} +
+{{ firstName }}
+{{ lastName }}
+{{ username }}
+{{ email }}
++ {{ labels.intro }} +
+ ++ {{ labels.endpointHint }} +
+{{ labels.noTokensYet }}
++ {{ labels.tokenShownOnce }} +
+{{ languageLabel }}
+{{ currencyLabel }}
+{{ message }}
+{{ labels.loadingPreferences }}
+{{ labels.emailNotifications }}
+{{ labels.emailDesc }}
+{{ labels.pushNotifications }}
+{{ labels.pushDesc }}
+{{ labels.inAppNotifications }}
+{{ labels.inAppDesc }}
+{{ labels.reminders }}
+{{ labels.remindersDesc }}
+{{ labels.financialInsights }}
+{{ labels.insightsDesc }}
+{{ labels.engagementReminders }}
+{{ labels.engagementDesc }}
+{{ message }}
+{{ walletLabel || '—' }}
+ {{ labels.currentlySelected }} +{{ groupLabel || '—' }}
+{{ labels.allowNegativeBalances }}
++ {{ labels.allowNegativeDesc }} +
+| + | {{ labels.date }} | +{{ labels.description }} | +{{ labels.amount }} | +{{ labels.type }} | +{{ labels.party }} | +{{ labels.category }} | +{{ labels.wallet }} | +{{ labels.confidence }} | +{{ labels.duplicate }} | +
|---|---|---|---|---|---|---|---|---|---|
| + + | ++ + | ++ + | ++ + | ++ + | ++ + + {{ labels.suggested }}: {{ suggestion.party }} + + | ++ + + {{ labels.suggested }}: {{ suggestion.category }} + + | ++ + + {{ labels.suggested }}: {{ suggestion.wallet }} + + | ++ + {{ Math.round(suggestion.confidence * 100) }}% + + | ++ + | +
|
+
+ {{ labels.existingTransaction }}:
+ {{
+ suggestion.duplicate.transaction_date
+ }}
+ {{
+ suggestion.duplicate.transaction_description
+ }}
+
+ {{ suggestion.duplicate.transaction_amount }}
+
+ {{
+ suggestion.duplicate.transaction_type
+ }}
+
+
+
+
+ |
+ |||||||||
{{ user.first_name }} {{ user.last_name }}
+{{ user.email }}
+