+
+
+
+
+
+
+
@@ -320,6 +325,8 @@ import IconCategory from '~icons/solar/widget-5-bold-duotone';
import { useSharedData } from '@/composables/useSharedData';
import { useNotifications } from '@/composables/useNotifications';
import { useWallets } from '@/composables/useWallets';
+import { useExtensionSlots } from '@/composables/useExtensionSlots';
+import DescriptorRenderer from '@/components/extensions/DescriptorRenderer.vue';
import configurationsApi from '@/services/api/configurationsApi';
import { CONFIGURATION_KEYS } from '@/utils/configurationKeys';
import { getCountries } from '@/utils/countries';
@@ -341,9 +348,37 @@ const { setComplete: setOnboardingComplete } = useOnboardingStatus();
const returnTo = computed(() => (route.query.returnTo as string) || '/dashboard');
const currentStep = ref(1);
-const totalSteps = 4;
-const progressPercentage = computed(() => (currentStep.value / totalSteps) * 100);
+// Onboarding is a data-driven list: built-in steps plus any plugin
+// contributions to the `onboarding.steps` slot, merged by order. The
+// completion step is pinned last.
+const { contributionsFor } = useExtensionSlots();
+const onboardingContributions = contributionsFor('onboarding.steps');
+
+const builtInSteps = [
+ { id: 'language', order: 10 },
+ { id: 'wallet', order: 20 },
+ { id: 'categories', order: 30 },
+ { id: 'complete', order: 1000 }
+];
+
+const steps = computed(() => {
+ const pluginSteps = onboardingContributions.value.map((contribution) => ({
+ id: contribution.key,
+ order: contribution.order,
+ contribution
+ }));
+
+ return [...builtInSteps, ...pluginSteps].sort((a, b) => a.order - b.order);
+});
+
+const totalSteps = computed(() => steps.value.length);
+const currentStepId = computed(() => steps.value[currentStep.value - 1]?.id);
+const currentStepContribution = computed(
+ () => steps.value[currentStep.value - 1]?.contribution ?? null
+);
+
+const progressPercentage = computed(() => (currentStep.value / totalSteps.value) * 100);
// Data from shared state
const incomeCategories = computed(() => sharedData.getIncomeCategories?.value || []);
@@ -393,7 +428,7 @@ const isWalletCurrencySetupValid = computed(() => {
});
const nextStep = () => {
- if (currentStep.value < totalSteps) {
+ if (currentStep.value < totalSteps.value) {
currentStep.value++;
}
};
diff --git a/pages/settings.vue b/pages/settings.vue
index cca16ad..e9184d4 100644
--- a/pages/settings.vue
+++ b/pages/settings.vue
@@ -22,6 +22,14 @@
+
+
+
+
+
+
+
+
@@ -30,8 +38,8 @@
diff --git a/services/api/index.ts b/services/api/index.ts
index 8ea31ba..ee29840 100644
--- a/services/api/index.ts
+++ b/services/api/index.ts
@@ -9,6 +9,7 @@ import remindersApi from './remindersApi';
import notificationsApi from './notificationsApi';
import importsApi from './importsApi';
import budgetsApi from './budgetsApi';
+import integrationsApi from './integrationsApi';
import transactionApi from '../transactionApi';
// Re-export individual services
@@ -23,6 +24,7 @@ export {
notificationsApi,
importsApi,
budgetsApi,
+ integrationsApi,
transactionApi
};
@@ -41,5 +43,6 @@ export const api = {
notifications: notificationsApi,
imports: importsApi,
budgets: budgetsApi,
+ integrations: integrationsApi,
transactions: transactionApi
} as const;
diff --git a/services/api/integrationsApi.ts b/services/api/integrationsApi.ts
new file mode 100644
index 0000000..757aecb
--- /dev/null
+++ b/services/api/integrationsApi.ts
@@ -0,0 +1,27 @@
+import type { Integration } from '~/types/integration';
+import { extractResponseData } from './apiHelpers';
+
+interface ApiResponse
{
+ success?: boolean;
+ message?: string;
+ data?: T;
+}
+
+/**
+ * Integrations API Service
+ * Lists installed integrations and the UI each one describes.
+ */
+const integrationsApi = {
+ /**
+ * Fetch all installed integrations.
+ * GET /integrations
+ */
+ async fetchAll(): Promise {
+ const api = useApi();
+ const response = await api>('/integrations');
+ return extractResponseData(response, []);
+ }
+};
+
+export default integrationsApi;
+export { integrationsApi };
diff --git a/tests/components/DescriptorRenderer.test.ts b/tests/components/DescriptorRenderer.test.ts
new file mode 100644
index 0000000..808585f
--- /dev/null
+++ b/tests/components/DescriptorRenderer.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { defineComponent, h } from 'vue';
+import DescriptorRenderer from '~/components/extensions/DescriptorRenderer.vue';
+import { useExtensionSlots } from '~/composables/useExtensionSlots';
+import type { SlotContribution } from '~/types/integration';
+
+const stubs = {
+ NuxtLink: { template: '' }
+};
+
+function contribution(overrides: Partial = {}): SlotContribution {
+ return {
+ key: 'statement-import',
+ slot: 'settings.integrations',
+ order: 100,
+ configured: true,
+ integration: {
+ key: 'statement-import',
+ name: 'Statement Import',
+ description: 'desc',
+ category: 'import',
+ icon: null,
+ feature_key: null,
+ configured: true,
+ entitled: true,
+ ui: null
+ },
+ ui: {
+ slots: ['settings.integrations'],
+ card: { title: 'Import bank statements', cta: 'Import', description: 'Upload a statement' },
+ component: null
+ },
+ ...overrides
+ };
+}
+
+describe('DescriptorRenderer', () => {
+ it('renders an integration card from the descriptor (no plugin JS)', () => {
+ const wrapper = mount(DescriptorRenderer, {
+ props: { contribution: contribution() },
+ global: { stubs }
+ });
+
+ expect(wrapper.text()).toContain('Import bank statements');
+ expect(wrapper.text()).toContain('Import');
+ });
+
+ it('shows a needs-setup badge when the integration is not configured', () => {
+ const wrapper = mount(DescriptorRenderer, {
+ props: { contribution: contribution({ configured: false }) },
+ global: { stubs }
+ });
+
+ expect(wrapper.text()).toContain('Needs setup');
+ });
+
+ it('renders a registered component when the descriptor sets ui.component (escape hatch)', () => {
+ const Custom = defineComponent({ render: () => h('div', 'CUSTOM-WIDGET') });
+ useExtensionSlots().registerComponent('statement-import.custom', Custom);
+
+ const wrapper = mount(DescriptorRenderer, {
+ props: {
+ contribution: contribution({
+ ui: {
+ slots: ['settings.integrations'],
+ card: { title: 'unused' },
+ component: 'statement-import.custom'
+ }
+ })
+ },
+ global: { stubs }
+ });
+
+ expect(wrapper.text()).toContain('CUSTOM-WIDGET');
+ expect(wrapper.text()).not.toContain('unused');
+ });
+});
diff --git a/tests/composables/useExtensionSlots.test.ts b/tests/composables/useExtensionSlots.test.ts
new file mode 100644
index 0000000..ecdca0f
--- /dev/null
+++ b/tests/composables/useExtensionSlots.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { ref, defineComponent, h } from 'vue';
+import type { Integration } from '~/types/integration';
+import { useExtensionSlots } from '~/composables/useExtensionSlots';
+
+const mockIntegrations = ref([]);
+
+vi.mock('~/composables/useIntegrations', () => ({
+ useIntegrations: () => ({ integrations: mockIntegrations })
+}));
+
+function makeIntegration(overrides: Partial): Integration {
+ return {
+ key: 'k',
+ name: 'Name',
+ description: null,
+ category: 'import',
+ icon: null,
+ feature_key: null,
+ configured: true,
+ entitled: true,
+ ui: null,
+ ...overrides
+ };
+}
+
+describe('useExtensionSlots', () => {
+ beforeEach(() => {
+ mockIntegrations.value = [];
+ });
+
+ it('collects only contributions that declare the slot', () => {
+ mockIntegrations.value = [
+ makeIntegration({ key: 'a', ui: { slots: ['settings.integrations'] } }),
+ makeIntegration({ key: 'b', ui: { slots: ['dashboard.widgets'] } })
+ ];
+
+ const { contributionsFor } = useExtensionSlots();
+ const settings = contributionsFor('settings.integrations');
+
+ expect(settings.value.map((c) => c.key)).toEqual(['a']);
+ });
+
+ it('orders onboarding contributions by their declared order', () => {
+ mockIntegrations.value = [
+ makeIntegration({
+ key: 'late',
+ ui: { slots: ['onboarding.steps'], onboarding: { step: 's', order: 90 } }
+ }),
+ makeIntegration({
+ key: 'early',
+ ui: { slots: ['onboarding.steps'], onboarding: { step: 's', order: 20 } }
+ })
+ ];
+
+ const { contributionsFor } = useExtensionSlots();
+ const steps = contributionsFor('onboarding.steps');
+
+ expect(steps.value.map((c) => c.key)).toEqual(['early', 'late']);
+ expect(steps.value.map((c) => c.order)).toEqual([20, 90]);
+ });
+
+ it('hides contributions the user is not entitled to', () => {
+ mockIntegrations.value = [
+ makeIntegration({ key: 'free', entitled: true, ui: { slots: ['settings.integrations'] } }),
+ makeIntegration({
+ key: 'paid',
+ feature_key: 'paid',
+ entitled: false,
+ ui: { slots: ['settings.integrations'] }
+ })
+ ];
+
+ const { contributionsFor } = useExtensionSlots();
+
+ expect(contributionsFor('settings.integrations').value.map((c) => c.key)).toEqual(['free']);
+ });
+
+ it('hides an unconfigured contribution by default', () => {
+ mockIntegrations.value = [
+ makeIntegration({
+ key: 'ready',
+ configured: true,
+ ui: { slots: ['settings.integrations'] }
+ }),
+ makeIntegration({
+ key: 'unset',
+ configured: false,
+ ui: { slots: ['settings.integrations'] }
+ })
+ ];
+
+ const { contributionsFor } = useExtensionSlots();
+
+ expect(contributionsFor('settings.integrations').value.map((c) => c.key)).toEqual(['ready']);
+ });
+
+ it('shows an unconfigured contribution when the descriptor opts in', () => {
+ mockIntegrations.value = [
+ makeIntegration({
+ key: 'unset',
+ configured: false,
+ ui: { slots: ['settings.integrations'], show_when_unconfigured: true }
+ })
+ ];
+
+ const { contributionsFor } = useExtensionSlots();
+ const items = contributionsFor('settings.integrations').value;
+
+ expect(items.map((c) => c.key)).toEqual(['unset']);
+ expect(items[0].configured).toBe(false);
+ });
+
+ it('resolves a component a plugin layer registered for a slot key', () => {
+ const { registerComponent, resolveComponent } = useExtensionSlots();
+ const Custom = defineComponent({ render: () => h('div', 'custom') });
+
+ expect(resolveComponent('plaid.connect')).toBeNull();
+ registerComponent('plaid.connect', Custom);
+ expect(resolveComponent('plaid.connect')).toBe(Custom);
+ });
+});
diff --git a/types/integration.ts b/types/integration.ts
new file mode 100644
index 0000000..df89b1f
--- /dev/null
+++ b/types/integration.ts
@@ -0,0 +1,73 @@
+/**
+ * Slots a plugin descriptor can target. These names are the contract shared
+ * with the backend (see trakli/internal#9); a contribution only renders in a
+ * declared slot.
+ */
+export type ExtensionSlot =
+ | 'settings.integrations'
+ | 'settings.tabs'
+ | 'onboarding.steps'
+ | 'dashboard.widgets'
+ | 'sidebar.nav';
+
+export interface IntegrationCardDescriptor {
+ title: string;
+ cta?: string;
+ description?: string;
+ href?: string;
+}
+
+export interface IntegrationConnectDescriptor {
+ type: string;
+ [key: string]: unknown;
+}
+
+export interface IntegrationOnboardingDescriptor {
+ step: string;
+ optional?: boolean;
+ order?: number;
+ title?: string;
+ description?: string;
+ href?: string;
+}
+
+export interface IntegrationUiDescriptor {
+ slots: ExtensionSlot[];
+ card?: IntegrationCardDescriptor | null;
+ connect?: IntegrationConnectDescriptor | null;
+ onboarding?: IntegrationOnboardingDescriptor | null;
+ /** Slot key a plugin Nuxt layer registered a real component for, else null. */
+ component?: string | null;
+ /**
+ * Render this contribution even when the integration is not configured,
+ * surfacing an explicit needs-setup state instead of hiding it. Default is
+ * to hide unconfigured contributions.
+ */
+ show_when_unconfigured?: boolean;
+}
+
+export interface Integration {
+ key: string;
+ name: string;
+ description: string | null;
+ category: string;
+ icon: string | null;
+ feature_key: string | null;
+ configured: boolean;
+ entitled: boolean;
+ ui: IntegrationUiDescriptor | null;
+}
+
+/**
+ * One integration's contribution to a single slot, flattened from its `ui`
+ * descriptor and carrying the gating state the registry resolved.
+ */
+export interface SlotContribution {
+ key: string;
+ slot: ExtensionSlot;
+ integration: Integration;
+ ui: IntegrationUiDescriptor;
+ order: number;
+ /** Operator has supplied what the integration needs to run. */
+ configured: boolean;
+}