mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-31 18:25:00 +03:00
feat(sponsors): drop the sidebar slot and simplify the Sponsors page
The sidebar no longer carries a sponsor card. The backend stops serving the "sidebar" slot, so a sponsors.json entry that still lists it keeps only its other slots and one listing nothing else is dropped. Its rotation, three-sponsor cap and collapsed-rail logo rendering go with it. The Sponsors page now lists only real sponsors: the "Your brand here" card shows only as the empty state instead of sitting in the grid and repeating the header's Become a sponsor button, and the Available placements section and its slot-occupancy helper are removed. Cards are larger, two per row on wide screens. Unused i18n keys are dropped from every locale.
This commit is contained in:
@@ -184,28 +184,37 @@
|
|||||||
.sponsor-card-card .sponsor-main {
|
.sponsor-card-card .sponsor-main {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 16px;
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-card .sponsor-logo {
|
.sponsor-card-card .sponsor-logo {
|
||||||
width: 56px;
|
width: 72px;
|
||||||
height: 56px;
|
height: 72px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-logo-fallback {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-body {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-title {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card-card .sponsor-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card-card .sponsor-visit {
|
.sponsor-card-card .sponsor-visit {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
font-size: 14px;
|
||||||
|
|
||||||
.sponsor-card-icon {
|
|
||||||
justify-content: center;
|
|
||||||
padding: 6px;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsor-card-icon .sponsor-logo {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import SponsorCard from './SponsorCard';
|
|||||||
const sponsor: Sponsor = {
|
const sponsor: Sponsor = {
|
||||||
id: 'acme-2026-10',
|
id: 'acme-2026-10',
|
||||||
name: 'Acme VPS',
|
name: 'Acme VPS',
|
||||||
slots: ['dashboard', 'sidebar', 'page', 'login'],
|
slots: ['dashboard', 'page', 'login'],
|
||||||
until: '2099-01-01T00:00:00Z',
|
until: '2099-01-01T00:00:00Z',
|
||||||
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
|
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
|
||||||
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
|
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
|
||||||
@@ -57,9 +57,8 @@ const meta = {
|
|||||||
argTypes: {
|
argTypes: {
|
||||||
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
|
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
|
||||||
variant: {
|
variant: {
|
||||||
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
|
description: 'banner (dashboard), compact (login) or card (Sponsors page).',
|
||||||
},
|
},
|
||||||
iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
|
|
||||||
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
|
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
|
||||||
},
|
},
|
||||||
args: { sponsor },
|
args: { sponsor },
|
||||||
@@ -81,7 +80,7 @@ export const Banner: Story = {
|
|||||||
export const Compact: Story = {
|
export const Compact: Story = {
|
||||||
args: { variant: 'compact', onClose: () => {} },
|
args: { variant: 'compact', onClose: () => {} },
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<div style={{ width: 204 }}>
|
<div style={{ width: 334 }}>
|
||||||
<SponsorCard {...args} />
|
<SponsorCard {...args} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -108,23 +107,6 @@ export const BannerLongCopy: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SidebarLongCopy: Story = {
|
|
||||||
args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
|
|
||||||
render: (args) => (
|
|
||||||
<div style={{ width: 204 }}>
|
|
||||||
<SponsorCard {...args} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
await expectNotClipped(canvasElement, '.sponsor-title');
|
|
||||||
await expectInsideCard(canvasElement, '.sponsor-text');
|
|
||||||
await expect(part(canvasElement, '.sponsor-main')).toHaveAttribute(
|
|
||||||
'title',
|
|
||||||
`${longSponsor.title.en}\n${longSponsor.text.en}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoginLongCopy: Story = {
|
export const LoginLongCopy: Story = {
|
||||||
args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
|
args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
@@ -135,6 +117,10 @@ export const LoginLongCopy: Story = {
|
|||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
await expectNotClipped(canvasElement, '.sponsor-title');
|
await expectNotClipped(canvasElement, '.sponsor-title');
|
||||||
await expectNotClipped(canvasElement, '.sponsor-text');
|
await expectNotClipped(canvasElement, '.sponsor-text');
|
||||||
|
await expect(part(canvasElement, '.sponsor-main')).toHaveAttribute(
|
||||||
|
'title',
|
||||||
|
`${longSponsor.title.en}\n${longSponsor.text.en}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,7 +137,3 @@ export const PageLongCopy: Story = {
|
|||||||
await expectNotClipped(canvasElement, '.sponsor-title');
|
await expectNotClipped(canvasElement, '.sponsor-title');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IconOnly: Story = {
|
|
||||||
args: { iconOnly: true },
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export type SponsorCardVariant = 'banner' | 'compact' | 'card';
|
|||||||
export interface SponsorCardProps {
|
export interface SponsorCardProps {
|
||||||
sponsor: Sponsor;
|
sponsor: Sponsor;
|
||||||
variant?: SponsorCardVariant;
|
variant?: SponsorCardVariant;
|
||||||
iconOnly?: boolean;
|
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,33 +35,13 @@ function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SponsorCard({
|
export default function SponsorCard({ sponsor, variant = 'banner', onClose }: SponsorCardProps) {
|
||||||
sponsor,
|
|
||||||
variant = 'banner',
|
|
||||||
iconOnly = false,
|
|
||||||
onClose,
|
|
||||||
}: SponsorCardProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.resolvedLanguage || i18n.language || 'en';
|
const lang = i18n.resolvedLanguage || i18n.language || 'en';
|
||||||
const title = pickLocale(sponsor.title, lang) || sponsor.name;
|
const title = pickLocale(sponsor.title, lang) || sponsor.name;
|
||||||
const text = pickLocale(sponsor.text, lang);
|
const text = pickLocale(sponsor.text, lang);
|
||||||
const tag = t('pages.sponsors.tag');
|
const tag = t('pages.sponsors.tag');
|
||||||
|
|
||||||
if (iconOnly) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className="sponsor-card sponsor-card-icon"
|
|
||||||
href={sponsor.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer sponsored"
|
|
||||||
title={`${tag} · ${title}`}
|
|
||||||
aria-label={`${tag}: ${title}`}
|
|
||||||
>
|
|
||||||
<SponsorLogo sponsor={sponsor} />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`sponsor-card sponsor-card-${variant}`}>
|
<div className={`sponsor-card sponsor-card-${variant}`}>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||||
import {
|
import {
|
||||||
@@ -9,47 +9,29 @@ import {
|
|||||||
} from '@/lib/sponsors';
|
} from '@/lib/sponsors';
|
||||||
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
|
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
|
||||||
|
|
||||||
const ROTATE_MS = 30_000;
|
|
||||||
|
|
||||||
interface SponsorSlotProps {
|
interface SponsorSlotProps {
|
||||||
slot: Slot;
|
slot: Slot;
|
||||||
variant?: SponsorCardVariant;
|
variant?: SponsorCardVariant;
|
||||||
iconOnly?: boolean;
|
|
||||||
rotate?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SponsorSlot({
|
export default function SponsorSlot({ slot, variant = 'banner', className }: SponsorSlotProps) {
|
||||||
slot,
|
|
||||||
variant = 'banner',
|
|
||||||
iconOnly,
|
|
||||||
rotate,
|
|
||||||
className,
|
|
||||||
}: SponsorSlotProps) {
|
|
||||||
const { data } = useSponsorsQuery();
|
const { data } = useSponsorsQuery();
|
||||||
const [, setDismissTick] = useState(0);
|
const [, setDismissTick] = useState(0);
|
||||||
const [index, setIndex] = useState(0);
|
|
||||||
|
|
||||||
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
|
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
|
||||||
const visible = sponsorsForSlot(data.sponsors, slot).filter(
|
const visible = sponsorsForSlot(data.sponsors, slot).filter(
|
||||||
(s) => !isSponsorDismissed(s.id, slot),
|
(s) => !isSponsorDismissed(s.id, slot),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!rotate || visible.length < 2) return;
|
|
||||||
const timer = window.setInterval(() => setIndex((i) => i + 1), ROTATE_MS);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [rotate, visible.length]);
|
|
||||||
|
|
||||||
if (visible.length === 0) return null;
|
if (visible.length === 0) return null;
|
||||||
const sponsor = visible[(rotate ? index : 0) % visible.length];
|
const sponsor = visible[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<SponsorCard
|
<SponsorCard
|
||||||
sponsor={sponsor}
|
sponsor={sponsor}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
iconOnly={iconOnly}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
dismissSponsor(sponsor.id, slot);
|
dismissSponsor(sponsor.id, slot);
|
||||||
setDismissTick((n) => n + 1);
|
setDismissTick((n) => n + 1);
|
||||||
|
|||||||
@@ -247,10 +247,6 @@
|
|||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sider-sponsor {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-pin {
|
.sidebar-pin {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import { formatPanelVersion } from '@/lib/panel-version';
|
|||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
|
||||||
import './AppSidebar.css';
|
import './AppSidebar.css';
|
||||||
|
|
||||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||||
@@ -452,13 +451,6 @@ export default function AppSidebar() {
|
|||||||
onClick={onMenuClick}
|
onClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
<div className="sider-footer">
|
<div className="sider-footer">
|
||||||
<SponsorSlot
|
|
||||||
slot="sidebar"
|
|
||||||
variant="compact"
|
|
||||||
iconOnly={railCollapsed}
|
|
||||||
rotate
|
|
||||||
className="sider-sponsor"
|
|
||||||
/>
|
|
||||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
</Layout.Sider>
|
</Layout.Sider>
|
||||||
@@ -544,7 +536,6 @@ export default function AppSidebar() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="drawer-footer">
|
<div className="drawer-footer">
|
||||||
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
|
|
||||||
<VersionBadge version={panelVersion} />
|
<VersionBadge version={panelVersion} />
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Sponsor } from '@/generated/types';
|
import type { Sponsor } from '@/generated/types';
|
||||||
|
|
||||||
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
|
export type SponsorSlot = 'dashboard' | 'page' | 'login';
|
||||||
|
|
||||||
const DISMISS_KEY = 'xui.sponsor.dismissed';
|
const DISMISS_KEY = 'xui.sponsor.dismissed';
|
||||||
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
|
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -38,25 +38,3 @@ export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now())
|
|||||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
|
|
||||||
export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
|
|
||||||
dashboard: 1,
|
|
||||||
login: 1,
|
|
||||||
sidebar: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface PlacementStatus {
|
|
||||||
count: number;
|
|
||||||
capacity?: number;
|
|
||||||
takenUntil?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// When full, a place frees up once enough bookings end to drop below capacity.
|
|
||||||
export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
|
|
||||||
const booked = sponsorsForSlot(sponsors, slot);
|
|
||||||
const capacity = SLOT_CAPACITY[slot];
|
|
||||||
if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
|
|
||||||
const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
|
|
||||||
return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,30 +29,14 @@
|
|||||||
color: #faad14;
|
color: #faad14;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsors-page .sponsors-section-title {
|
|
||||||
margin: 32px 0 12px;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-yourbrand {
|
.sponsors-yourbrand {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100%;
|
padding: 40px 24px;
|
||||||
min-height: 170px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1.5px dashed var(--ant-color-border);
|
border: 1.5px dashed var(--ant-color-border);
|
||||||
border-radius: var(--ant-border-radius-lg, 8px);
|
border-radius: var(--ant-border-radius-lg, 8px);
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-yourbrand.is-large {
|
|
||||||
align-items: center;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 40px 24px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--bg-card, transparent);
|
background: var(--bg-card, transparent);
|
||||||
}
|
}
|
||||||
@@ -82,58 +66,6 @@
|
|||||||
color: var(--ant-color-text-secondary);
|
color: var(--ant-color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsors-yourbrand:not(.is-large) .ant-btn {
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
height: 100%;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid var(--ant-color-border-secondary);
|
|
||||||
border-radius: var(--ant-border-radius-lg, 8px);
|
|
||||||
background: var(--bg-card, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 17px;
|
|
||||||
color: var(--ant-color-primary);
|
|
||||||
background: var(--ant-color-primary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ant-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-body {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-status {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-status .ant-tag {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sponsors-placement-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--ant-color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sponsors-page .content-area {
|
.sponsors-page .content-area {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
|
import { Button, Col, ConfigProvider, Layout, Row, Spin, Typography } from 'antd';
|
||||||
import {
|
import { CrownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
CrownOutlined,
|
|
||||||
DashboardOutlined,
|
|
||||||
LoginOutlined,
|
|
||||||
MenuUnfoldOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||||
import SponsorCard from '@/components/sponsor/SponsorCard';
|
import SponsorCard from '@/components/sponsor/SponsorCard';
|
||||||
import { IntlUtil } from '@/utils';
|
import { sponsorsForSlot } from '@/lib/sponsors';
|
||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
|
||||||
import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
|
|
||||||
import './SponsorsPage.css';
|
import './SponsorsPage.css';
|
||||||
|
|
||||||
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
|
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
|
||||||
@@ -36,10 +27,10 @@ function BecomeButton({ contact, block }: { contact?: string; block?: boolean })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
|
function YourBrandCard({ contact }: { contact?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
|
<div className="sponsors-yourbrand">
|
||||||
<span className="sponsors-yourbrand-icon">
|
<span className="sponsors-yourbrand-icon">
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
</span>
|
</span>
|
||||||
@@ -56,34 +47,6 @@ export default function SponsorsPage() {
|
|||||||
const { data, fetched } = useSponsorsQuery();
|
const { data, fetched } = useSponsorsQuery();
|
||||||
const sponsors = sponsorsForSlot(data.sponsors, 'page');
|
const sponsors = sponsorsForSlot(data.sponsors, 'page');
|
||||||
|
|
||||||
const { datepicker } = useDatepicker();
|
|
||||||
const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
|
|
||||||
{
|
|
||||||
slot: 'dashboard',
|
|
||||||
icon: <DashboardOutlined />,
|
|
||||||
title: t('pages.sponsors.placementDashboard'),
|
|
||||||
desc: t('pages.sponsors.placementDashboardDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slot: 'sidebar',
|
|
||||||
icon: <MenuUnfoldOutlined />,
|
|
||||||
title: t('pages.sponsors.placementSidebar'),
|
|
||||||
desc: t('pages.sponsors.placementSidebarDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slot: 'login',
|
|
||||||
icon: <LoginOutlined />,
|
|
||||||
title: t('pages.sponsors.placementLogin'),
|
|
||||||
desc: t('pages.sponsors.placementLoginDesc'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slot: 'page',
|
|
||||||
icon: <CrownOutlined />,
|
|
||||||
title: t('pages.sponsors.placementPage'),
|
|
||||||
desc: t('pages.sponsors.placementPageDesc'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const pageClass = useMemo(() => {
|
const pageClass = useMemo(() => {
|
||||||
const classes = ['sponsors-page'];
|
const classes = ['sponsors-page'];
|
||||||
if (isDark) classes.push('is-dark');
|
if (isDark) classes.push('is-dark');
|
||||||
@@ -110,63 +73,17 @@ export default function SponsorsPage() {
|
|||||||
|
|
||||||
<Spin spinning={!fetched} delay={200}>
|
<Spin spinning={!fetched} delay={200}>
|
||||||
{sponsors.length === 0 ? (
|
{sponsors.length === 0 ? (
|
||||||
fetched && <YourBrandCard contact={data.contact} large />
|
fetched && <YourBrandCard contact={data.contact} />
|
||||||
) : (
|
) : (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
{sponsors.map((sponsor) => (
|
{sponsors.map((sponsor) => (
|
||||||
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
|
<Col key={sponsor.id} xs={24} lg={12}>
|
||||||
<SponsorCard sponsor={sponsor} variant="card" />
|
<SponsorCard sponsor={sponsor} variant="card" />
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
{data.contact && (
|
|
||||||
<Col xs={24} sm={12} xl={8}>
|
|
||||||
<YourBrandCard contact={data.contact} />
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
||||||
<Typography.Title level={5} className="sponsors-section-title">
|
|
||||||
{t('pages.sponsors.placements')}
|
|
||||||
</Typography.Title>
|
|
||||||
<Row gutter={[16, 16]}>
|
|
||||||
{placements.map((p) => {
|
|
||||||
const status = placementStatus(data.sponsors, p.slot);
|
|
||||||
return (
|
|
||||||
<Col key={p.slot} xs={24} sm={12} xl={6}>
|
|
||||||
<div className="sponsors-placement">
|
|
||||||
<span className="sponsors-placement-icon">{p.icon}</span>
|
|
||||||
<div className="sponsors-placement-body">
|
|
||||||
<div className="sponsors-placement-title">{p.title}</div>
|
|
||||||
<div className="sponsors-placement-desc">{p.desc}</div>
|
|
||||||
<div className="sponsors-placement-status">
|
|
||||||
{status.takenUntil ? (
|
|
||||||
<Tag color="orange">
|
|
||||||
{t('pages.sponsors.takenUntil', {
|
|
||||||
date: IntlUtil.formatDate(status.takenUntil, datepicker),
|
|
||||||
})}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag color="green">{t('pages.sponsors.available')}</Tag>
|
|
||||||
)}
|
|
||||||
{status.count > 0 && !status.takenUntil && (
|
|
||||||
<Tag>
|
|
||||||
{t('pages.sponsors.activeCount', {
|
|
||||||
count:
|
|
||||||
status.capacity && status.capacity > 1
|
|
||||||
? `${status.count}/${status.capacity}`
|
|
||||||
: status.count,
|
|
||||||
})}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Row>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { afterEach, describe, expect, test } from 'vitest';
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import {
|
import { SPONSOR_DISMISS_MS, dismissSponsor, isSponsorDismissed, pickLocale } from '@/lib/sponsors';
|
||||||
SPONSOR_DISMISS_MS,
|
|
||||||
dismissSponsor,
|
|
||||||
isSponsorDismissed,
|
|
||||||
pickLocale,
|
|
||||||
placementStatus,
|
|
||||||
} from '@/lib/sponsors';
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -33,10 +27,10 @@ describe('sponsor dismissal', () => {
|
|||||||
const t0 = 1_800_000_000_000;
|
const t0 = 1_800_000_000_000;
|
||||||
|
|
||||||
test('hides only the dismissed slot until the window elapses', () => {
|
test('hides only the dismissed slot until the window elapses', () => {
|
||||||
dismissSponsor('acme', 'sidebar', t0);
|
dismissSponsor('acme', 'login', t0);
|
||||||
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
|
expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
|
||||||
expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
|
expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
|
||||||
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
|
expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prunes expired entries on the next dismiss', () => {
|
test('prunes expired entries on the next dismiss', () => {
|
||||||
@@ -48,51 +42,6 @@ describe('sponsor dismissal', () => {
|
|||||||
|
|
||||||
test('corrupt storage is treated as nothing dismissed', () => {
|
test('corrupt storage is treated as nothing dismissed', () => {
|
||||||
localStorage.setItem('xui.sponsor.dismissed', 'not json');
|
localStorage.setItem('xui.sponsor.dismissed', 'not json');
|
||||||
expect(isSponsorDismissed('acme', 'sidebar', t0)).toBe(false);
|
expect(isSponsorDismissed('acme', 'login', t0)).toBe(false);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('placementStatus', () => {
|
|
||||||
const sp = (id: string, slots: string[], until: string) => ({
|
|
||||||
id,
|
|
||||||
name: id,
|
|
||||||
slots,
|
|
||||||
until,
|
|
||||||
title: {},
|
|
||||||
text: {},
|
|
||||||
link: 'https://x.example/',
|
|
||||||
});
|
|
||||||
|
|
||||||
test('exclusive slot is taken until the latest booking ends', () => {
|
|
||||||
const sponsors = [
|
|
||||||
sp('a', ['dashboard'], '2026-11-01T00:00:00Z'),
|
|
||||||
sp('b', ['dashboard'], '2026-12-15T00:00:00Z'),
|
|
||||||
];
|
|
||||||
expect(placementStatus(sponsors, 'dashboard')).toEqual({
|
|
||||||
count: 2,
|
|
||||||
capacity: 1,
|
|
||||||
takenUntil: '2026-12-15T00:00:00Z',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sidebar is full at three and frees up when the earliest ends', () => {
|
|
||||||
const sponsors = [
|
|
||||||
sp('a', ['sidebar'], '2026-12-01T00:00:00Z'),
|
|
||||||
sp('b', ['sidebar'], '2026-10-20T00:00:00Z'),
|
|
||||||
sp('c', ['sidebar'], '2026-11-10T00:00:00Z'),
|
|
||||||
];
|
|
||||||
expect(placementStatus(sponsors, 'sidebar')).toEqual({
|
|
||||||
count: 3,
|
|
||||||
capacity: 3,
|
|
||||||
takenUntil: '2026-10-20T00:00:00Z',
|
|
||||||
});
|
|
||||||
expect(placementStatus(sponsors.slice(0, 2), 'sidebar')).toEqual({ count: 2, capacity: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uncapped page slot reports only a count', () => {
|
|
||||||
expect(placementStatus([sp('a', ['page'], '2026-12-01T00:00:00Z')], 'page')).toEqual({
|
|
||||||
count: 1,
|
|
||||||
capacity: undefined,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ const (
|
|||||||
sponsorsErrTTL = 10 * time.Minute
|
sponsorsErrTTL = 10 * time.Minute
|
||||||
maxSponsorsBytes = 256 << 10
|
maxSponsorsBytes = 256 << 10
|
||||||
maxLogoBytes = 256 << 10
|
maxLogoBytes = 256 << 10
|
||||||
maxSidebarSlots = 3
|
|
||||||
localSponsorsDir = "../sponsors/3X"
|
localSponsorsDir = "../sponsors/3X"
|
||||||
sponsorLogoPath = "/sponsors/logo/"
|
sponsorLogoPath = "/sponsors/logo/"
|
||||||
)
|
)
|
||||||
@@ -67,7 +66,7 @@ var (
|
|||||||
|
|
||||||
// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
|
// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
|
||||||
sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
|
sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
|
||||||
sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
|
sponsorSlots = map[string]bool{"dashboard": true, "page": true, "login": true}
|
||||||
|
|
||||||
sponsorsMu sync.Mutex
|
sponsorsMu sync.Mutex
|
||||||
sponsorsRaw *SponsorList
|
sponsorsRaw *SponsorList
|
||||||
@@ -217,7 +216,6 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
|||||||
if strings.HasPrefix(raw.Contact, "https://") {
|
if strings.HasPrefix(raw.Contact, "https://") {
|
||||||
out.Contact = raw.Contact
|
out.Contact = raw.Contact
|
||||||
}
|
}
|
||||||
sidebarTaken := 0
|
|
||||||
for _, sp := range raw.Sponsors {
|
for _, sp := range raw.Sponsors {
|
||||||
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
||||||
disabled := sp.Enable != nil && !*sp.Enable
|
disabled := sp.Enable != nil && !*sp.Enable
|
||||||
@@ -233,18 +231,13 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
|||||||
}
|
}
|
||||||
slots := make([]string, 0, len(sp.Slots))
|
slots := make([]string, 0, len(sp.Slots))
|
||||||
for _, slot := range sp.Slots {
|
for _, slot := range sp.Slots {
|
||||||
// The sidebar rotates, so capping it keeps each paid card on screen long enough.
|
if sponsorSlots[slot] {
|
||||||
if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
|
slots = append(slots, slot)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
slots = append(slots, slot)
|
|
||||||
}
|
}
|
||||||
if len(slots) == 0 {
|
if len(slots) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if slices.Contains(slots, "sidebar") {
|
|
||||||
sidebarTaken++
|
|
||||||
}
|
|
||||||
sp.Slots = slots
|
sp.Slots = slots
|
||||||
out.Sponsors = append(out.Sponsors, sp)
|
out.Sponsors = append(out.Sponsors, sp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -22,7 +21,7 @@ func validSponsor() Sponsor {
|
|||||||
return Sponsor{
|
return Sponsor{
|
||||||
ID: "acme",
|
ID: "acme",
|
||||||
Name: "Acme",
|
Name: "Acme",
|
||||||
Slots: []string{"dashboard", "sidebar"},
|
Slots: []string{"dashboard"},
|
||||||
Until: sponsorTestNow.Add(24 * time.Hour),
|
Until: sponsorTestNow.Add(24 * time.Hour),
|
||||||
Logo: "acme.png",
|
Logo: "acme.png",
|
||||||
Link: "https://acme.example/",
|
Link: "https://acme.example/",
|
||||||
@@ -59,34 +58,6 @@ func TestActiveSponsorsFilters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) {
|
|
||||||
raw := &SponsorList{}
|
|
||||||
for _, id := range []string{"expired", "a", "b", "c", "d", "e"} {
|
|
||||||
sp := validSponsor()
|
|
||||||
sp.ID = id
|
|
||||||
sp.Slots = []string{"sidebar", "page"}
|
|
||||||
if id == "expired" {
|
|
||||||
sp.Until = sponsorTestNow
|
|
||||||
}
|
|
||||||
if id == "e" {
|
|
||||||
sp.Slots = []string{"sidebar"}
|
|
||||||
}
|
|
||||||
raw.Sponsors = append(raw.Sponsors, sp)
|
|
||||||
}
|
|
||||||
got := activeSponsors(raw, sponsorTestNow)
|
|
||||||
want := map[string][]string{
|
|
||||||
"a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"},
|
|
||||||
}
|
|
||||||
if len(got.Sponsors) != len(want) {
|
|
||||||
t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want))
|
|
||||||
}
|
|
||||||
for _, sp := range got.Sponsors {
|
|
||||||
if !slices.Equal(sp.Slots, want[sp.ID]) {
|
|
||||||
t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestActiveSponsorsLogoName(t *testing.T) {
|
func TestActiveSponsorsLogoName(t *testing.T) {
|
||||||
cases := []struct{ logo, want string }{
|
cases := []struct{ logo, want string }{
|
||||||
{"acme.png", "/sponsors/logo/acme.png"},
|
{"acme.png", "/sponsors/logo/acme.png"},
|
||||||
@@ -114,7 +85,7 @@ func TestActiveSponsorsLogoName(t *testing.T) {
|
|||||||
|
|
||||||
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
|
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
|
||||||
sp := validSponsor()
|
sp := validSponsor()
|
||||||
sp.Slots = []string{"subpage", "login"}
|
sp.Slots = []string{"subpage", "sidebar", "login"}
|
||||||
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
|
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
|
||||||
if len(got.Sponsors) != 1 {
|
if len(got.Sponsors) != 1 {
|
||||||
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
|
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "راعٍ",
|
"tag": "راعٍ",
|
||||||
"visit": "زيارة",
|
"visit": "زيارة",
|
||||||
"yourBrand": "علامتك التجارية هنا",
|
"yourBrand": "علامتك التجارية هنا",
|
||||||
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم.",
|
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم."
|
||||||
"placements": "المواضع المتاحة",
|
|
||||||
"placementDashboard": "لافتة لوحة المعلومات",
|
|
||||||
"placementDashboardDesc": "موضع مميز أعلى لوحة المعلومات، راعٍ واحد في كل مرة.",
|
|
||||||
"placementSidebar": "بطاقة الشريط الجانبي",
|
|
||||||
"placementSidebarDesc": "تظهر في كل صفحات اللوحة وتتناوب بين الرعاة.",
|
|
||||||
"placementLogin": "صفحة تسجيل الدخول",
|
|
||||||
"placementLoginDesc": "يراها المسؤول عند كل تسجيل دخول.",
|
|
||||||
"placementPage": "صفحة الرعاة",
|
|
||||||
"placementPageDesc": "مدرجة هنا مع الشعار والوصف والرابط.",
|
|
||||||
"available": "متاح",
|
|
||||||
"takenUntil": "محجوز حتى {date}",
|
|
||||||
"activeCount": "{count} نشط"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "Sponsor",
|
"tag": "Sponsor",
|
||||||
"visit": "Visit",
|
"visit": "Visit",
|
||||||
"yourBrand": "Your brand here",
|
"yourBrand": "Your brand here",
|
||||||
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day.",
|
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day."
|
||||||
"placements": "Available placements",
|
|
||||||
"placementDashboard": "Overview banner",
|
|
||||||
"placementDashboardDesc": "Premium spot at the top of the dashboard, one sponsor at a time.",
|
|
||||||
"placementSidebar": "Sidebar card",
|
|
||||||
"placementSidebarDesc": "Shown on every panel page, rotating between sponsors.",
|
|
||||||
"placementLogin": "Login page",
|
|
||||||
"placementLoginDesc": "Seen by the admin at every sign-in.",
|
|
||||||
"placementPage": "Sponsors page",
|
|
||||||
"placementPageDesc": "Listed here with logo, description and link.",
|
|
||||||
"available": "Available",
|
|
||||||
"takenUntil": "Taken until {date}",
|
|
||||||
"activeCount": "{count} active"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "Patrocinador",
|
"tag": "Patrocinador",
|
||||||
"visit": "Visitar",
|
"visit": "Visitar",
|
||||||
"yourBrand": "Tu marca aquí",
|
"yourBrand": "Tu marca aquí",
|
||||||
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día.",
|
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día."
|
||||||
"placements": "Espacios disponibles",
|
|
||||||
"placementDashboard": "Banner del panel",
|
|
||||||
"placementDashboardDesc": "Espacio premium en la parte superior del panel, un patrocinador a la vez.",
|
|
||||||
"placementSidebar": "Tarjeta lateral",
|
|
||||||
"placementSidebarDesc": "Se muestra en todas las páginas del panel, rotando entre patrocinadores.",
|
|
||||||
"placementLogin": "Página de inicio de sesión",
|
|
||||||
"placementLoginDesc": "La ve el administrador en cada inicio de sesión.",
|
|
||||||
"placementPage": "Página de patrocinadores",
|
|
||||||
"placementPageDesc": "Aparece aquí con logo, descripción y enlace.",
|
|
||||||
"available": "Disponible",
|
|
||||||
"takenUntil": "Ocupado hasta {date}",
|
|
||||||
"activeCount": "{count} activos"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "اسپانسر",
|
"tag": "اسپانسر",
|
||||||
"visit": "مشاهده",
|
"visit": "مشاهده",
|
||||||
"yourBrand": "جای برند شما",
|
"yourBrand": "جای برند شما",
|
||||||
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز میکنند.",
|
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز میکنند."
|
||||||
"placements": "جایگاههای موجود",
|
|
||||||
"placementDashboard": "بنر داشبورد",
|
|
||||||
"placementDashboardDesc": "جایگاه ویژه بالای صفحهی داشبورد، هر بار فقط یک اسپانسر.",
|
|
||||||
"placementSidebar": "کارت سایدبار",
|
|
||||||
"placementSidebarDesc": "در همهی صفحات پنل نمایش داده میشود و بین اسپانسرها چرخش دارد.",
|
|
||||||
"placementLogin": "صفحهی ورود",
|
|
||||||
"placementLoginDesc": "ادمین در هر بار ورود آن را میبیند.",
|
|
||||||
"placementPage": "صفحهی اسپانسرها",
|
|
||||||
"placementPageDesc": "همراه با لوگو، توضیحات و لینک در همین صفحه.",
|
|
||||||
"available": "خالی",
|
|
||||||
"takenUntil": "پر شده تا {date}",
|
|
||||||
"activeCount": "{count} فعال"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "Sponsor",
|
"tag": "Sponsor",
|
||||||
"visit": "Kunjungi",
|
"visit": "Kunjungi",
|
||||||
"yourBrand": "Merek Anda di sini",
|
"yourBrand": "Merek Anda di sini",
|
||||||
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari.",
|
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari."
|
||||||
"placements": "Slot yang tersedia",
|
|
||||||
"placementDashboard": "Banner dasbor",
|
|
||||||
"placementDashboardDesc": "Tempat premium di atas dasbor, satu sponsor dalam satu waktu.",
|
|
||||||
"placementSidebar": "Kartu sidebar",
|
|
||||||
"placementSidebarDesc": "Tampil di setiap halaman panel, bergantian antar sponsor.",
|
|
||||||
"placementLogin": "Halaman login",
|
|
||||||
"placementLoginDesc": "Dilihat admin setiap kali masuk.",
|
|
||||||
"placementPage": "Halaman sponsor",
|
|
||||||
"placementPageDesc": "Tercantum di sini dengan logo, deskripsi, dan tautan.",
|
|
||||||
"available": "Tersedia",
|
|
||||||
"takenUntil": "Terisi hingga {date}",
|
|
||||||
"activeCount": "{count} aktif"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "スポンサー",
|
"tag": "スポンサー",
|
||||||
"visit": "訪問",
|
"visit": "訪問",
|
||||||
"yourBrand": "ここにあなたのブランドを",
|
"yourBrand": "ここにあなたのブランドを",
|
||||||
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。",
|
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。"
|
||||||
"placements": "掲載枠",
|
|
||||||
"placementDashboard": "ダッシュボードバナー",
|
|
||||||
"placementDashboardDesc": "ダッシュボード上部のプレミアム枠。一度に 1 社のみ。",
|
|
||||||
"placementSidebar": "サイドバーカード",
|
|
||||||
"placementSidebarDesc": "パネルの全ページに表示され、スポンサー間でローテーションします。",
|
|
||||||
"placementLogin": "ログインページ",
|
|
||||||
"placementLoginDesc": "管理者がログインするたびに表示されます。",
|
|
||||||
"placementPage": "スポンサーページ",
|
|
||||||
"placementPageDesc": "ロゴ、説明、リンク付きでここに掲載されます。",
|
|
||||||
"available": "空きあり",
|
|
||||||
"takenUntil": "{date} まで予約済み",
|
|
||||||
"activeCount": "{count} 件掲載中"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "Patrocinador",
|
"tag": "Patrocinador",
|
||||||
"visit": "Visitar",
|
"visit": "Visitar",
|
||||||
"yourBrand": "Sua marca aqui",
|
"yourBrand": "Sua marca aqui",
|
||||||
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias.",
|
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias."
|
||||||
"placements": "Espaços disponíveis",
|
|
||||||
"placementDashboard": "Banner do painel",
|
|
||||||
"placementDashboardDesc": "Espaço premium no topo do painel, um patrocinador por vez.",
|
|
||||||
"placementSidebar": "Cartão lateral",
|
|
||||||
"placementSidebarDesc": "Exibido em todas as páginas do painel, alternando entre patrocinadores.",
|
|
||||||
"placementLogin": "Página de login",
|
|
||||||
"placementLoginDesc": "Visto pelo administrador a cada login.",
|
|
||||||
"placementPage": "Página de patrocinadores",
|
|
||||||
"placementPageDesc": "Listado aqui com logo, descrição e link.",
|
|
||||||
"available": "Disponível",
|
|
||||||
"takenUntil": "Ocupado até {date}",
|
|
||||||
"activeCount": "{count} ativos"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "Спонсор",
|
"tag": "Спонсор",
|
||||||
"visit": "Перейти",
|
"visit": "Перейти",
|
||||||
"yourBrand": "Здесь может быть ваш бренд",
|
"yourBrand": "Здесь может быть ваш бренд",
|
||||||
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI.",
|
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI."
|
||||||
"placements": "Доступные места",
|
|
||||||
"placementDashboard": "Баннер на главной",
|
|
||||||
"placementDashboardDesc": "Премиум-место вверху панели, один спонсор за раз.",
|
|
||||||
"placementSidebar": "Карточка в боковой панели",
|
|
||||||
"placementSidebarDesc": "Показывается на всех страницах панели, спонсоры чередуются.",
|
|
||||||
"placementLogin": "Страница входа",
|
|
||||||
"placementLoginDesc": "Администратор видит её при каждом входе.",
|
|
||||||
"placementPage": "Страница спонсоров",
|
|
||||||
"placementPageDesc": "Размещение здесь с логотипом, описанием и ссылкой.",
|
|
||||||
"available": "Свободно",
|
|
||||||
"takenUntil": "Занято до {date}",
|
|
||||||
"activeCount": "Активных: {count}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "Sponsor",
|
"tag": "Sponsor",
|
||||||
"visit": "Ziyaret et",
|
"visit": "Ziyaret et",
|
||||||
"yourBrand": "Markanız burada",
|
"yourBrand": "Markanız burada",
|
||||||
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın.",
|
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın."
|
||||||
"placements": "Mevcut yerleşimler",
|
|
||||||
"placementDashboard": "Genel bakış afişi",
|
|
||||||
"placementDashboardDesc": "Panonun üstünde premium alan, aynı anda tek sponsor.",
|
|
||||||
"placementSidebar": "Kenar çubuğu kartı",
|
|
||||||
"placementSidebarDesc": "Tüm panel sayfalarında gösterilir, sponsorlar arasında döner.",
|
|
||||||
"placementLogin": "Giriş sayfası",
|
|
||||||
"placementLoginDesc": "Yönetici her girişte görür.",
|
|
||||||
"placementPage": "Sponsorlar sayfası",
|
|
||||||
"placementPageDesc": "Logo, açıklama ve bağlantıyla burada listelenir.",
|
|
||||||
"available": "Müsait",
|
|
||||||
"takenUntil": "{date} tarihine kadar dolu",
|
|
||||||
"activeCount": "{count} aktif"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "Спонсор",
|
"tag": "Спонсор",
|
||||||
"visit": "Перейти",
|
"visit": "Перейти",
|
||||||
"yourBrand": "Тут може бути ваш бренд",
|
"yourBrand": "Тут може бути ваш бренд",
|
||||||
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI.",
|
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI."
|
||||||
"placements": "Доступні місця",
|
|
||||||
"placementDashboard": "Банер на головній",
|
|
||||||
"placementDashboardDesc": "Преміум-місце вгорі панелі, один спонсор за раз.",
|
|
||||||
"placementSidebar": "Картка в бічній панелі",
|
|
||||||
"placementSidebarDesc": "Показується на всіх сторінках панелі, спонсори чергуються.",
|
|
||||||
"placementLogin": "Сторінка входу",
|
|
||||||
"placementLoginDesc": "Адміністратор бачить її під час кожного входу.",
|
|
||||||
"placementPage": "Сторінка спонсорів",
|
|
||||||
"placementPageDesc": "Розміщення тут із логотипом, описом і посиланням.",
|
|
||||||
"available": "Вільно",
|
|
||||||
"takenUntil": "Зайнято до {date}",
|
|
||||||
"activeCount": "Активних: {count}"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "Tài trợ",
|
"tag": "Tài trợ",
|
||||||
"visit": "Truy cập",
|
"visit": "Truy cập",
|
||||||
"yourBrand": "Thương hiệu của bạn ở đây",
|
"yourBrand": "Thương hiệu của bạn ở đây",
|
||||||
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày.",
|
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày."
|
||||||
"placements": "Vị trí hiện có",
|
|
||||||
"placementDashboard": "Banner tổng quan",
|
|
||||||
"placementDashboardDesc": "Vị trí cao cấp ở đầu bảng điều khiển, mỗi lần một nhà tài trợ.",
|
|
||||||
"placementSidebar": "Thẻ thanh bên",
|
|
||||||
"placementSidebarDesc": "Hiển thị trên mọi trang của panel, luân phiên giữa các nhà tài trợ.",
|
|
||||||
"placementLogin": "Trang đăng nhập",
|
|
||||||
"placementLoginDesc": "Quản trị viên thấy mỗi lần đăng nhập.",
|
|
||||||
"placementPage": "Trang nhà tài trợ",
|
|
||||||
"placementPageDesc": "Được liệt kê tại đây với logo, mô tả và liên kết.",
|
|
||||||
"available": "Còn trống",
|
|
||||||
"takenUntil": "Đã đặt đến {date}",
|
|
||||||
"activeCount": "{count} đang hoạt động"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "赞助",
|
"tag": "赞助",
|
||||||
"visit": "访问",
|
"visit": "访问",
|
||||||
"yourBrand": "您的品牌展示位",
|
"yourBrand": "您的品牌展示位",
|
||||||
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。",
|
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。"
|
||||||
"placements": "可用展示位",
|
|
||||||
"placementDashboard": "概览横幅",
|
|
||||||
"placementDashboardDesc": "仪表盘顶部的高级位置,每次仅一家赞助商。",
|
|
||||||
"placementSidebar": "侧边栏卡片",
|
|
||||||
"placementSidebarDesc": "在面板所有页面显示,并在赞助商之间轮换。",
|
|
||||||
"placementLogin": "登录页",
|
|
||||||
"placementLoginDesc": "管理员每次登录时都能看到。",
|
|
||||||
"placementPage": "赞助商页面",
|
|
||||||
"placementPageDesc": "在此页面展示徽标、简介和链接。",
|
|
||||||
"available": "可预订",
|
|
||||||
"takenUntil": "已预订至 {date}",
|
|
||||||
"activeCount": "{count} 个进行中"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
@@ -2423,19 +2423,7 @@
|
|||||||
"tag": "贊助",
|
"tag": "贊助",
|
||||||
"visit": "造訪",
|
"visit": "造訪",
|
||||||
"yourBrand": "您的品牌展示位",
|
"yourBrand": "您的品牌展示位",
|
||||||
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。",
|
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。"
|
||||||
"placements": "可用展示位",
|
|
||||||
"placementDashboard": "總覽橫幅",
|
|
||||||
"placementDashboardDesc": "儀表板頂部的高級位置,每次僅一家贊助商。",
|
|
||||||
"placementSidebar": "側邊欄卡片",
|
|
||||||
"placementSidebarDesc": "在面板所有頁面顯示,並在贊助商之間輪換。",
|
|
||||||
"placementLogin": "登入頁",
|
|
||||||
"placementLoginDesc": "管理員每次登入時都能看到。",
|
|
||||||
"placementPage": "贊助商頁面",
|
|
||||||
"placementPageDesc": "在此頁面展示標誌、簡介與連結。",
|
|
||||||
"available": "可預訂",
|
|
||||||
"takenUntil": "已預訂至 {date}",
|
|
||||||
"activeCount": "{count} 個進行中"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tgbot": {
|
"tgbot": {
|
||||||
|
|||||||
Reference in New Issue
Block a user