Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions components/TSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
</ul>
</li>
</ul>

<div v-if="!isCompact" class="sidebar-ext">
<ExtensionSlot name="sidebar.nav" />
</div>
</nav>

<hr v-if="!isCompact" class="divider" />
Expand Down Expand Up @@ -126,6 +130,7 @@ import {
import { Sparkles, Scale, Coins } from 'lucide-vue-next';
import { computed } from 'vue';
import Logo from './Logo.vue';
import ExtensionSlot from '@/components/extensions/ExtensionSlot.vue';
import { useSidebar } from '@/composables/useSidebar';
import { useRoute, useRouter } from 'vue-router';

Expand Down
200 changes: 200 additions & 0 deletions components/extensions/DescriptorRenderer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<template>
<!-- Escape hatch: a plugin Nuxt layer registered a real component. -->
<component :is="resolved" v-if="resolved" :contribution="contribution" @next="$emit('next')" />

<!-- Sidebar nav entry -->
<NuxtLink v-else-if="variant === 'sidebar.nav'" :to="href || '#'" class="ext-nav-item">
<component :is="icon" class="ext-nav-icon" />
<span class="ext-nav-label">{{ card?.title || integration.name }}</span>
</NuxtLink>

<!-- Onboarding step -->
<div v-else-if="variant === 'onboarding.steps'" class="ext-onboarding">
<div class="ext-onboarding-icon">
<component :is="icon" />
</div>
<h2 class="ext-onboarding-title">{{ onboarding?.title || card?.title || integration.name }}</h2>
<p v-if="onboarding?.description || card?.description" class="ext-onboarding-desc">
{{ onboarding?.description || card?.description }}
</p>
<div class="ext-onboarding-actions">
<TButton
v-if="href"
:to="href"
:text="card?.cta || 'Open'"
variant="outline"
:full-width="false"
/>
<TButton :text="'Continue'" :full-width="false" @click="$emit('next')" />
</div>
</div>

<!-- Default: an integration card (settings.integrations, dashboard.widgets) -->
<TCard v-else class="ext-card">
<div class="ext-card-head">
<span class="ext-card-icon"><component :is="icon" /></span>
<h3 class="ext-card-title">{{ card?.title || integration.name }}</h3>
<span v-if="!contribution.configured" class="ext-card-badge">{{ 'Needs setup' }}</span>
</div>
<p v-if="card?.description || integration.description" class="ext-card-desc">
{{ card?.description || integration.description }}
</p>
<TButton
v-if="card?.cta"
:to="href || undefined"
:text="card.cta"
variant="outline"
size="small"
:full-width="false"
@click="$emit('action', contribution)"
/>
</TCard>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import * as LucideIcons from 'lucide-vue-next';
import TCard from '@/components/TCard.vue';
import TButton from '@/components/TButton.vue';
import type { SlotContribution } from '~/types/integration';
import { useExtensionSlots } from '~/composables/useExtensionSlots';

const props = defineProps<{ contribution: SlotContribution }>();
defineEmits<{ next: []; action: [SlotContribution] }>();

const { resolveComponent } = useExtensionSlots();

const integration = computed(() => props.contribution.integration);
const ui = computed(() => props.contribution.ui);
const card = computed(() => ui.value.card);
const onboarding = computed(() => ui.value.onboarding);
const variant = computed(() => props.contribution.slot);
const href = computed(() => onboarding.value?.href || card.value?.href || null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The priority of href resolution is slightly inconsistent. In the template (line 18-19), it checks href (which includes onboarding), but for the card variant (line 39) it uses href || undefined. Centralizing this logic in the computed property prevents potential dead links.

Suggested change
const href = computed(() => onboarding.value?.href || card.value?.href || null);
const href = computed(() => ui.value.onboarding?.href || ui.value.card?.href || null);

const resolved = computed(() => resolveComponent(ui.value.component));

// Resolve the descriptor's icon name (kebab or PascalCase) to a lucide
// component, with a generic glyph fallback so every plugin surface carries an
// icon like its built-in neighbors.
const lucide = LucideIcons as unknown as Record<string, Component>;
const icon = computed<Component>(() => {
const name = integration.value.icon;
if (name) {
const pascal = name.replace(/(^\w|[-_ ]\w)/g, (m) => m.replace(/[-_ ]/, '').toUpperCase());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex used for PascalCase conversion is slightly inefficient. While functional for short strings like icon names, a more standard approach or utility would be cleaner. More importantly, ensure that lucide[name] check accounts for the case where name might be kebab-case while Lucide exports are PascalCase.

Suggested change
const pascal = name.replace(/(^\w|[-_ ]\w)/g, (m) => m.replace(/[-_ ]/, '').toUpperCase());
const pascal = name.split(/[-_ ]/).map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('');

return lucide[name] || lucide[pascal] || lucide.Puzzle;
}
return lucide.Puzzle;
});
</script>

<style lang="scss" scoped>
@use '@/assets/scss/_variables.scss' as *;

.ext-card-head {
display: flex;
align-items: center;
gap: $spacing-3;
margin-bottom: $spacing-2;
}

.ext-card-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: $radius-lg;
background: $primary-light;
color: $primary;
flex-shrink: 0;

:deep(svg) {
width: 20px;
height: 20px;
}
}

.ext-card-title {
font-size: $font-size-base;
font-weight: $font-semibold;
color: $text-primary;
margin: 0;
}

.ext-card-badge {
margin-left: auto;
font-size: $font-size-xs;
font-weight: $font-medium;
color: $text-muted;
background: $bg-gray;
border-radius: 999px;
padding: 2px 8px;
flex-shrink: 0;
}

.ext-card-desc {
font-size: $font-size-sm;
color: $text-secondary;
margin: 0 0 $spacing-4;
}

.ext-onboarding {
text-align: center;
}

.ext-onboarding-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin: 0 auto $spacing-4;
border-radius: 50%;
background: $primary-light;
color: $primary;

:deep(svg) {
width: 28px;
height: 28px;
}
}

.ext-onboarding-title {
font-size: $font-size-lg;
font-weight: $font-semibold;
color: $text-primary;
}

.ext-onboarding-desc {
font-size: $font-size-base;
color: $text-secondary;
margin: $spacing-2 0 $spacing-6;
}

.ext-onboarding-actions {
display: flex;
gap: $spacing-3;
justify-content: center;
}

.ext-nav-item {
display: flex;
align-items: center;
gap: $spacing-2;
padding: $spacing-2 $spacing-3;
border-radius: $radius-md;
color: $text-secondary;
text-decoration: none;

&:hover {
background: $primary-light;
color: $primary;
}
}

.ext-nav-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
</style>
20 changes: 20 additions & 0 deletions components/extensions/ExtensionSlot.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<DescriptorRenderer
v-for="contribution in contributions"
:key="contribution.key"
:contribution="contribution"
@action="$emit('action', $event)"
/>
</template>

<script setup lang="ts">
import DescriptorRenderer from '@/components/extensions/DescriptorRenderer.vue';
import type { ExtensionSlot, SlotContribution } from '~/types/integration';
import { useExtensionSlots } from '~/composables/useExtensionSlots';

const props = defineProps<{ name: ExtensionSlot }>();
defineEmits<{ action: [SlotContribution] }>();

const { contributionsFor } = useExtensionSlots();
const contributions = contributionsFor(props.name);
</script>
8 changes: 8 additions & 0 deletions composables/useDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export const useDataManager = () => {

isInitialized.value = true;
lastInitTime.value = Date.now();

// Integrations are non-critical and additive. Load them after the token
// cookie has settled (fresh login writes it asynchronously) and never let
// a failure here disrupt the session.
const { load: loadIntegrations } = useIntegrations();
loadIntegrations().catch(() => []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silencing the error with an empty array in .catch(() => []) might make debugging difficult if the integrations API fails for reasons other than auth (e.g., 500 error). It is better to let useIntegrations handle the error logging (which it does) but perhaps not suppress the promise rejection entirely if upstream needs to know.

Suggested change
loadIntegrations().catch(() => []);
loadIntegrations();

} catch (err) {
error.value = extractApiErrors(err);
isInitialized.value = false;
Expand All @@ -66,9 +72,11 @@ export const useDataManager = () => {
try {
const { clearAllData } = useSharedData();
const { clearTransactions } = useTransactions();
const { clear: clearIntegrations } = useIntegrations();

clearAllData();
clearTransactions();
clearIntegrations();
} catch (err) {
console.warn('[DataManager] Error clearing data:', err);
}
Expand Down
75 changes: 75 additions & 0 deletions composables/useExtensionSlots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { computed, markRaw, ref } from 'vue';
import type { Component } from 'vue';
import type { ExtensionSlot, SlotContribution } from '~/types/integration';
import { useIntegrations } from '~/composables/useIntegrations';

const DEFAULT_ORDER = 100;

// Components a plugin Nuxt layer registers for a slot key (the escape hatch).
const componentRegistry = ref<Record<string, Component>>({});

/**
* Collects what installed integrations contribute to each named slot, ordered
* and gated, and resolves the optional custom component a plugin layer can
* register. The common cases render with no plugin JS; a registered component
* is the escape hatch.
*/
export const useExtensionSlots = () => {
const { integrations } = useIntegrations();

const contributionsFor = (slot: ExtensionSlot) =>
computed<SlotContribution[]>(() => {
const items: SlotContribution[] = [];

for (const integration of integrations.value) {
const ui = integration.ui;
if (!ui || !Array.isArray(ui.slots) || !ui.slots.includes(slot)) {
continue;
}

// Hard gate: never surface something the user is not entitled to. The
// server owns the paid-vs-free decision; the client only honors it.
if (!integration.entitled) {
continue;
}

// Hide contributions that are not ready to use unless the descriptor
// asks to surface an explicit needs-setup state.
if (!integration.configured && !ui.show_when_unconfigured) {
continue;
}

items.push({
key: integration.key,
slot,
integration: integration as SlotContribution['integration'],
ui,
order:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for calculating the order is specific to onboarding. To keep this composable generic, it's better to default to the specific UI order if available, or fall back to the global default. This makes it easier to add ordering to other slots later.

Suggested change
order:
order:
ui.onboarding?.order ?? ui.card?.order ?? DEFAULT_ORDER,

slot === 'onboarding.steps' ? (ui.onboarding?.order ?? DEFAULT_ORDER) : DEFAULT_ORDER,
configured: integration.configured
});
}

return items.sort((a, b) => a.order - b.order);
});

/**
* Register a real component for a slot key, used when a descriptor sets
* `ui.component`. Called from a plugin Nuxt layer.
*/
const registerComponent = (slotKey: string, component: Component): void => {
componentRegistry.value = {
...componentRegistry.value,
[slotKey]: markRaw(component)
};
};

const resolveComponent = (slotKey: string | null | undefined): Component | null =>
slotKey ? (componentRegistry.value[slotKey] ?? null) : null;

return {
contributionsFor,
registerComponent,
resolveComponent
};
};
46 changes: 46 additions & 0 deletions composables/useIntegrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ref, readonly } from 'vue';
import type { Integration } from '~/types/integration';
import integrationsApi from '~/services/api/integrationsApi';

// Module-level shared state (mirrors useSharedData's pattern).
const integrations = ref<Integration[]>([]);
const loading = ref(false);
const loaded = ref(false);

export const useIntegrations = () => {
const load = async (force = false): Promise<Integration[]> => {
// Never call the authenticated endpoint without a token: a 401 here would
// trip the global handler and log the user out over a non-critical fetch.
const { token } = useAuth();
if (!token.value) return integrations.value;

if (loading.value) return integrations.value;
if (loaded.value && !force) return integrations.value;

loading.value = true;
try {
integrations.value = await integrationsApi.fetchAll();
loaded.value = true;
} catch (error) {
console.error('Error loading integrations:', error);
integrations.value = [];
} finally {
loading.value = false;
}

return integrations.value;
};

const clear = () => {
integrations.value = [];
loaded.value = false;
};

return {
integrations: readonly(integrations),
loading: readonly(loading),
loaded: readonly(loaded),
load,
clear
};
};
Loading
Loading