feat(editor): Show Gateway credits promotions on community nodes (#40010)

This commit is contained in:
Elias Meire 2026-10-01 14:59:20 +00:00 committed by GitHub
parent 88d6579459
commit 190d7c0dc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 302 additions and 2 deletions

View File

@ -1,5 +1,12 @@
import type { INodeTypeDescription } from 'n8n-workflow';
/** Temporary Gateway credits offer for a community node. Dates are ISO datetimes. */
export type GatewayCreditsPromotion = {
text: string;
startsAt?: string | null;
endsAt?: string | null;
};
export type CommunityNodeType = {
id: number;
authorGithubUrl: string;
@ -19,4 +26,5 @@ export type CommunityNodeType = {
nodeDescription: INodeTypeDescription;
isInstalled: boolean;
nodeVersions?: Array<{ npmVersion: string; checksum: string }>;
gatewayCreditsPromotion?: GatewayCreditsPromotion | null;
};

View File

@ -1,3 +1,4 @@
import type { GatewayCreditsPromotion } from '@n8n/api-types';
import type { INodeTypeDescription } from 'n8n-workflow';
import { paginatedRequest, type StrapiFilters } from '@/utils/strapi-utils';
@ -21,6 +22,7 @@ export type StrapiCommunityNodeType = {
nodeDescription: INodeTypeDescription;
nodeVersions?: Array<{ npmVersion: string; checksum: string }>;
aiNodeSdkVersion?: number;
gatewayCreditsPromotion?: GatewayCreditsPromotion | null;
};
/**

View File

@ -574,6 +574,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
visibleNodeTypesByInputConnectionTypeNames,
isConfigurableNode,
communityNodesAndActions,
vettedCommunityNodeTypes,
communityNodeType,
officialCommunityNodeTypes,
unofficialCommunityNodeTypes,

View File

@ -55,6 +55,8 @@ import CredentialTypeSelector from './CredentialTypeSelector.vue';
import { useQuickConnect } from '../../quickConnect/composables/useQuickConnect';
import QuickConnectButton from '../../quickConnect/components/QuickConnectButton.vue';
import QuickConnectBanner from '../../quickConnect/components/QuickConnectBanner.vue';
import { useGatewayCreditsPromotion } from '../../gatewayCreditsPromotion/useGatewayCreditsPromotion';
import GatewayCreditsPromotion from '../../gatewayCreditsPromotion/GatewayCreditsPromotion.vue';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
import type { ProjectType } from '@/features/collaboration/projects/projects.types';
@ -129,6 +131,9 @@ const chatPanelStore = useChatPanelStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const { getQuickConnectOption } = useQuickConnect();
const { promotionText } = useGatewayCreditsPromotion({
credentialType: () => props.credentialType.name,
});
onBeforeMount(async () => {
uiStore.activeCredentialType = props.credentialType.name;
@ -461,6 +466,7 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
:text="quickConnectBannerText"
:disclaimer="quickConnectOption?.disclaimer"
/>
<GatewayCreditsPromotion v-if="promotionText" :text="promotionText" />
<QuickConnectButton
:service-name="serviceName"
:credential-type-name="credentialType.name"
@ -470,6 +476,7 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
</template>
<template v-else>
<GatewayCreditsPromotion v-if="promotionText" :text="promotionText" />
<N8nCallout
v-if="
!isInstanceAiCredentialHelpAvailable &&

View File

@ -0,0 +1,14 @@
import { createComponentRenderer } from '@/__tests__/render';
import GatewayCreditsPromotion from './GatewayCreditsPromotion.vue';
describe('GatewayCreditsPromotion', () => {
const renderComponent = createComponentRenderer(GatewayCreditsPromotion);
it('renders the text as plain text', () => {
const text = '<b>Free</b> until October 10, 2026.';
const { getByTestId } = renderComponent({ props: { text } });
expect(getByTestId('gateway-credits-promotion').textContent?.trim()).toBe(text);
expect(getByTestId('gateway-credits-promotion').querySelector('b')).toBeNull();
});
});

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import { N8nCallout } from '@n8n/design-system';
defineProps<{ text: string }>();
</script>
<template>
<N8nCallout theme="secondary" iconless data-test-id="gateway-credits-promotion">
{{ text }}
</N8nCallout>
</template>

View File

@ -0,0 +1,139 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import type { CommunityNodeType, FrontendSettings, GatewayCreditsPromotion } from '@n8n/api-types';
import type { INodeTypeDescription } from 'n8n-workflow';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useGatewayCreditsPromotion } from './useGatewayCreditsPromotion';
const gateway = {
supportedNodes: [] as string[],
supportedCredentialTypes: ['typeSafeAiApi'],
};
vi.mock('@/app/stores/aiGateway.store', () => ({
useAiGatewayStore: () => ({
fetchConfig: vi.fn().mockResolvedValue(undefined),
isCredentialTypeSupported: (type: string) => gateway.supportedCredentialTypes.includes(type),
isNodeSupported: (name: string) => gateway.supportedNodes.includes(name.replace(/Tool$/, '')),
}),
}));
const setGatewayEnabled = (enabled: boolean) => {
useSettingsStore().settings = { aiGateway: { enabled } } as FrontendSettings;
};
const NODE_TYPE = '@typesafe-ai/n8n-nodes-typesafe-ai.typeSafeAi';
const TEXT = 'Free until October 10, 2026.';
// Strapi serves node descriptions without `credentials`; only installed node types have them.
const communityNode = (name: string, promotion: GatewayCreditsPromotion) =>
({
name,
nodeDescription: { name },
gatewayCreditsPromotion: promotion,
}) as CommunityNodeType;
const setup = (promotion: GatewayCreditsPromotion) => {
const store = useNodeTypesStore();
store.vettedCommunityNodeTypes = new Map(
[NODE_TYPE, `${NODE_TYPE}Tool`].map((name) => [name, communityNode(name, promotion)]),
);
Object.assign(store, {
getNodeType: (name: string) =>
name === NODE_TYPE
? ({ name, credentials: [{ name: 'typeSafeAiApi' }] } as INodeTypeDescription)
: null,
});
};
describe('useGatewayCreditsPromotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
setGatewayEnabled(true);
gateway.supportedNodes = [NODE_TYPE];
gateway.supportedCredentialTypes = ['typeSafeAiApi'];
vi.useFakeTimers({ now: new Date('2026-06-01T00:00:00Z') });
});
afterEach(() => {
vi.useRealTimers();
});
it('returns the text inside the window, by node type and by credential type', () => {
setup({ text: TEXT, startsAt: '2026-01-01T00:00:00Z', endsAt: '2026-10-10T00:00:00Z' });
expect(useGatewayCreditsPromotion({ nodeType: NODE_TYPE }).promotionText.value).toBe(TEXT);
expect(
useGatewayCreditsPromotion({ credentialType: 'typeSafeAiApi' }).promotionText.value,
).toBe(TEXT);
});
it('returns the text for the Tool variant', () => {
setup({ text: TEXT });
expect(useGatewayCreditsPromotion({ nodeType: `${NODE_TYPE}Tool` }).promotionText.value).toBe(
TEXT,
);
});
it('returns nothing when Gateway credits are disabled', () => {
setup({ text: TEXT });
setGatewayEnabled(false);
expect(useGatewayCreditsPromotion({ nodeType: NODE_TYPE }).promotionText.value).toBeUndefined();
});
it('returns nothing when the gateway does not support the node', () => {
setup({ text: TEXT });
gateway.supportedNodes = [];
expect(useGatewayCreditsPromotion({ nodeType: NODE_TYPE }).promotionText.value).toBeUndefined();
});
it('returns nothing before startsAt', () => {
setup({ text: TEXT, startsAt: '2026-07-01T00:00:00Z' });
expect(useGatewayCreditsPromotion({ nodeType: NODE_TYPE }).promotionText.value).toBeUndefined();
});
it('returns nothing after endsAt', () => {
setup({ text: TEXT, endsAt: '2026-05-01T00:00:00Z' });
expect(useGatewayCreditsPromotion({ nodeType: NODE_TYPE }).promotionText.value).toBeUndefined();
});
it('returns nothing when the text is null', () => {
setup({ text: null } as unknown as GatewayCreditsPromotion);
expect(useGatewayCreditsPromotion({ nodeType: NODE_TYPE }).promotionText.value).toBeUndefined();
});
it('returns nothing by credential type when the gateway does not support the credential', () => {
setup({ text: TEXT });
gateway.supportedCredentialTypes = [];
expect(
useGatewayCreditsPromotion({ credentialType: 'typeSafeAiApi' }).promotionText.value,
).toBeUndefined();
});
it('skips an expired promotion on another node with the same credential', () => {
const store = useNodeTypesStore();
store.vettedCommunityNodeTypes = new Map([
[
'expired.node',
communityNode('expired.node', { text: 'old', endsAt: '2026-05-01T00:00:00Z' }),
],
[NODE_TYPE, communityNode(NODE_TYPE, { text: TEXT })],
]);
Object.assign(store, {
getNodeType: () => ({ credentials: [{ name: 'typeSafeAiApi' }] }) as INodeTypeDescription,
});
gateway.supportedNodes = [NODE_TYPE, 'expired.node'];
expect(
useGatewayCreditsPromotion({ credentialType: 'typeSafeAiApi' }).promotionText.value,
).toBe(TEXT);
});
});

View File

@ -0,0 +1,71 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import type { GatewayCreditsPromotion } from '@n8n/api-types';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
const isActive = (promotion: GatewayCreditsPromotion | null | undefined, now: number) =>
!!promotion?.text?.trim() &&
(!promotion.startsAt || now >= Date.parse(promotion.startsAt)) &&
(!promotion.endsAt || now < Date.parse(promotion.endsAt));
/**
* Gateway credits promotion of a community node, looked up by node type or,
* when there is no node type, by credential type.
*/
export function useGatewayCreditsPromotion({
nodeType,
credentialType,
}: {
nodeType?: MaybeRefOrGetter<string | undefined>;
credentialType?: MaybeRefOrGetter<string | undefined>;
}) {
const nodeTypesStore = useNodeTypesStore();
const settingsStore = useSettingsStore();
const aiGatewayStore = useAiGatewayStore();
const isEnabled = computed(() => settingsStore.isAiGatewayEnabled);
if (isEnabled.value) void aiGatewayStore.fetchConfig();
// The credential modal can open before a workflow loads the community node catalog.
if (
isEnabled.value &&
credentialType !== undefined &&
nodeTypesStore.vettedCommunityNodeTypes.size === 0
) {
void nodeTypesStore.fetchCommunityNodePreviews();
}
const communityNode = computed(() => {
const nodeTypeName = toValue(nodeType);
if (nodeTypeName) return nodeTypesStore.communityNodeType(nodeTypeName);
const credentialTypeName = toValue(credentialType);
if (!credentialTypeName || !aiGatewayStore.isCredentialTypeSupported(credentialTypeName)) {
return undefined;
}
// Strapi serves node descriptions without `credentials`, so read them from the installed node type.
return [...nodeTypesStore.vettedCommunityNodeTypes.values()].find(
({ name, gatewayCreditsPromotion }) =>
isActive(gatewayCreditsPromotion, Date.now()) &&
nodeTypesStore
.getNodeType(name)
?.credentials?.some(({ name: credential }) => credential === credentialTypeName),
);
});
const promotionText = computed(() => {
const node = communityNode.value;
if (
!isEnabled.value ||
!node ||
!isActive(node.gatewayCreditsPromotion, Date.now()) ||
!aiGatewayStore.isNodeSupported(node.name)
) {
return undefined;
}
return node.gatewayCreditsPromotion?.text;
});
return { promotionText };
}

View File

@ -65,6 +65,8 @@ import { useResizeObserver } from '@vueuse/core';
import CommunityNodeFooter from '@/features/settings/communityNodes/components/nodeCreator/CommunityNodeFooter.vue';
import CommunityNodeUpdateInfo from '@/features/settings/communityNodes/components/nodeCreator/CommunityNodeUpdateInfo.vue';
import QuickConnectBanner from '@/features/credentials/quickConnect/components/QuickConnectBanner.vue';
import { useGatewayCreditsPromotion } from '@/features/credentials/gatewayCreditsPromotion/useGatewayCreditsPromotion';
import GatewayCreditsPromotion from '@/features/credentials/gatewayCreditsPromotion/GatewayCreditsPromotion.vue';
import { useQuickConnect } from '@/features/credentials/quickConnect/composables/useQuickConnect';
import { N8nBlockUi, N8nIcon, N8nNotice, N8nText } from '@n8n/design-system';
@ -281,6 +283,12 @@ const showQuickConnectBanner = computed(
!props.isEmbeddedInCanvas,
);
const { promotionText } = useGatewayCreditsPromotion({ nodeType: () => node.value?.type });
const showGatewayCreditsPromotion = computed(
() =>
!!promotionText.value && !isReadOnly.value && !isDemoPreview.value && !props.isEmbeddedInCanvas,
);
const showNoParametersNotice = computed(
() =>
!isDisplayingCredentials.value &&
@ -785,6 +793,11 @@ function handleSelectAction(params: INodeParameters) {
:disclaimer="quickConnect?.disclaimer"
:class="$style.quickConnectBanner"
/>
<GatewayCreditsPromotion
v-if="showGatewayCreditsPromotion"
:text="promotionText ?? ''"
:class="$style.gatewayCreditsPromotion"
/>
<NodeCredentials
v-if="!isEmbeddedInCanvas && !isDemoPreview"
:node="node"
@ -914,6 +927,10 @@ function handleSelectAction(params: INodeParameters) {
margin-top: var(--spacing--sm);
}
.gatewayCreditsPromotion {
margin-top: var(--spacing--sm);
}
.uiBlocker {
border-radius: 0;
}

View File

@ -28,6 +28,7 @@ const getCommunityNodeAttributes = vi.fn();
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getCommunityNodeAttributes,
communityNodeType: vi.fn(),
getNodeType: vi.fn(),
getAllNodeTypes: vi.fn().mockReturnValue({
nodeTypes: {},

View File

@ -12,6 +12,8 @@ import { N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
import CommunityNodeUpdateInfo from './CommunityNodeUpdateInfo.vue';
import { useQuickConnect } from '@/features/credentials/quickConnect/composables/useQuickConnect';
import QuickConnectBanner from '@/features/credentials/quickConnect/components/QuickConnectBanner.vue';
import { useGatewayCreditsPromotion } from '@/features/credentials/gatewayCreditsPromotion/useGatewayCreditsPromotion';
import GatewayCreditsPromotion from '@/features/credentials/gatewayCreditsPromotion/GatewayCreditsPromotion.vue';
const { activeViewStack } = useViewStacks();
@ -34,6 +36,7 @@ const quickConnect = computed(() => {
const pkg = packageName.value;
return pkg ? getQuickConnectOptionByPackageName(pkg) : undefined;
});
const { promotionText } = useGatewayCreditsPromotion({ nodeType: nodeTypeName });
const nodeTypesStore = useNodeTypesStore();
@ -180,6 +183,11 @@ onMounted(async () => {
:text="quickConnect?.text"
:disclaimer="quickConnect?.disclaimer"
/>
<GatewayCreditsPromotion
v-if="promotionText"
:text="promotionText"
:class="$style.gatewayCreditsPromotion"
/>
<ContactAdministratorToInstall v-if="!isAdminOrOwner && !communityNodeDetails?.installed" />
</div>
</template>
@ -202,6 +210,9 @@ onMounted(async () => {
.description {
margin: var(--spacing--md) 0;
}
.gatewayCreditsPromotion {
margin-top: var(--spacing--2xs);
}
.separator {
height: var(--border-width);
background: var(--color--foreground);

View File

@ -37,6 +37,8 @@ import CommunityNodeFooter from '@/features/settings/communityNodes/components/n
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import { useQuickConnect } from '@/features/credentials/quickConnect/composables/useQuickConnect';
import QuickConnectBanner from '@/features/credentials/quickConnect/components/QuickConnectBanner.vue';
import { useGatewayCreditsPromotion } from '@/features/credentials/gatewayCreditsPromotion/useGatewayCreditsPromotion';
import GatewayCreditsPromotion from '@/features/credentials/gatewayCreditsPromotion/GatewayCreditsPromotion.vue';
import { N8nCallout, N8nInfoTip } from '@n8n/design-system';
const emit = defineEmits<{
@ -117,6 +119,14 @@ const quickConnect = computed(() => {
return null;
});
// CommunityNodeInfo shows the promotion when community node details are open.
const { promotionText } = useGatewayCreditsPromotion({
nodeType: () => {
const items = useViewStacks().activeViewStack.items;
return !communityNodeDetails.value && items?.length ? items[0].key : undefined;
},
});
const hasNoTriggerActions = computed(
() =>
parseCategoryActions(
@ -273,8 +283,13 @@ const callouts = computed<INodeCreateElement[]>(() => []);
<ItemsRenderer :elements="callouts" :class="$style.items" @selected="onSelected" />
<CommunityNodeInfo v-if="communityNodeDetails" />
<div :class="$style.banner" v-if="quickConnect">
<QuickConnectBanner :text="quickConnect.text" :disclaimer="quickConnect.disclaimer" />
<div v-if="quickConnect || promotionText" :class="$style.banner">
<QuickConnectBanner
v-if="quickConnect"
:text="quickConnect.text"
:disclaimer="quickConnect.disclaimer"
/>
<GatewayCreditsPromotion v-if="promotionText" :text="promotionText" />
</div>
<OrderSwitcher v-if="rootView" :root-view="rootView">
<template v-if="shouldShowTriggers" #triggers>
@ -423,6 +438,9 @@ const callouts = computed<INodeCreateElement[]>(() => []);
z-index: 1;
}
.banner {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
margin: var(--spacing--sm);
margin-top: 0;
}