mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat(editor): Show Gateway credits promotions on community nodes (#40010)
This commit is contained in:
parent
88d6579459
commit
190d7c0dc0
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -574,6 +574,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
|||
visibleNodeTypesByInputConnectionTypeNames,
|
||||
isConfigurableNode,
|
||||
communityNodesAndActions,
|
||||
vettedCommunityNodeTypes,
|
||||
communityNodeType,
|
||||
officialCommunityNodeTypes,
|
||||
unofficialCommunityNodeTypes,
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user