Преглед изворни кода

feat(panel): add sponsor slots fed from sponsors.sanaei.dev

Monthly sponsor placements need to change without cutting a panel
release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors
repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in
four slots: an overview banner, a rotating sidebar card (max three), the
login page and a new Sponsors page that also lists open placements.

An entry shows only while enable is not false and until is in the
future; links must be https and logos are png/webp/jpg by name only.
The list is cached for an hour and the last good copy survives upstream
failures; logos are proxied through /sponsors/logo/:name with failures
cached, so CSP stays 'self' and admin browsers never reach a third
party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads
a sibling ../sponsors/3X checkout so edits can be previewed before push.
MHSanaei пре 11 часа
родитељ
комит
fd7b3559bc
47 измењених фајлова са 2323 додато и 10 уклоњено
  1. 12 1
      docs/content/docs/en/reference/api/authentication.mdx
  2. 131 0
      docs/public/openapi.json
  3. 131 0
      frontend/public/openapi.json
  4. 4 0
      frontend/src/api/http-init.ts
  5. 23 0
      frontend/src/api/queries/useSponsorsQuery.ts
  6. 1 0
      frontend/src/api/queryKeys.ts
  7. 7 0
      frontend/src/components/command-palette/CommandPalette.tsx
  8. 216 0
      frontend/src/components/sponsor/SponsorCard.css
  9. 73 0
      frontend/src/components/sponsor/SponsorCard.stories.tsx
  10. 101 0
      frontend/src/components/sponsor/SponsorCard.tsx
  11. 60 0
      frontend/src/components/sponsor/SponsorSlot.tsx
  12. 31 0
      frontend/src/generated/examples.ts
  13. 78 0
      frontend/src/generated/schemas.ts
  14. 17 0
      frontend/src/generated/types.ts
  15. 19 0
      frontend/src/generated/zod.ts
  16. 1 0
      frontend/src/hooks/usePageTitle.ts
  17. 4 0
      frontend/src/layouts/AppSidebar.css
  18. 13 0
      frontend/src/layouts/AppSidebar.tsx
  19. 62 0
      frontend/src/lib/sponsors.ts
  20. 7 0
      frontend/src/pages/api-docs/endpoints.ts
  21. 3 0
      frontend/src/pages/index/IndexPage.tsx
  22. 4 0
      frontend/src/pages/login/LoginPage.css
  23. 2 0
      frontend/src/pages/login/LoginPage.tsx
  24. 142 0
      frontend/src/pages/sponsors/SponsorsPage.css
  25. 176 0
      frontend/src/pages/sponsors/SponsorsPage.tsx
  26. 2 0
      frontend/src/routes.tsx
  27. 6 3
      frontend/src/styles/page-shell.css
  28. 98 0
      frontend/src/test/sponsors.test.tsx
  29. 1 1
      frontend/vite.config.js
  30. 26 0
      internal/web/controller/index.go
  31. 5 4
      internal/web/routes_contract_test.go
  32. 246 0
      internal/web/service/panel/sponsor.go
  33. 334 0
      internal/web/service/panel/sponsor_test.go
  34. 22 0
      internal/web/translation/ar-EG.json
  35. 22 0
      internal/web/translation/en-US.json
  36. 22 0
      internal/web/translation/es-ES.json
  37. 22 0
      internal/web/translation/fa-IR.json
  38. 22 0
      internal/web/translation/id-ID.json
  39. 22 0
      internal/web/translation/ja-JP.json
  40. 22 0
      internal/web/translation/pt-BR.json
  41. 22 0
      internal/web/translation/ru-RU.json
  42. 22 0
      internal/web/translation/tr-TR.json
  43. 22 0
      internal/web/translation/uk-UA.json
  44. 22 0
      internal/web/translation/vi-VN.json
  45. 22 0
      internal/web/translation/zh-CN.json
  46. 22 0
      internal/web/translation/zh-TW.json
  47. 1 1
      tools/openapigen/main.go

+ 12 - 1
docs/content/docs/en/reference/api/authentication.mdx

@@ -22,6 +22,12 @@ _openapi:
         this — the middleware short-circuits CSRF for authenticated API
         requests.
       url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests'
+    - depth: 2
+      title: Public. Active paid sponsor placements read from the project
+        sponsors.json (cached for 1h); expired entries are dropped. Logos are
+        proxied by the panel at /sponsors/logo/{name}. Used by the login page
+        and panel sponsor slots.
+      url: '#public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
     - depth: 2
       title: Returns whether 2FA is enabled on the panel — used by the login page to
         decide whether to show the OTP field.
@@ -39,6 +45,11 @@ _openapi:
           this — the middleware short-circuits CSRF for authenticated API
           requests.
         id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests
+      - content: Public. Active paid sponsor placements read from the project
+          sponsors.json (cached for 1h); expired entries are dropped. Logos are
+          proxied by the panel at /sponsors/logo/{name}. Used by the login page
+          and panel sponsor slots.
+        id: public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots
       - content: Returns whether 2FA is enabled on the panel — used by the login page to
           decide whether to show the OTP field.
         id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
@@ -54,7 +65,7 @@ export default function Layout(props) {
   return (
     <>
       {props.children}
-      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
+      <Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
     </>
   );
 }

+ 131 - 0
docs/public/openapi.json

@@ -4135,6 +4135,84 @@
         ],
         "type": "object"
       },
+      "Sponsor": {
+        "description": "Sponsor is one paid placement published in the repo's sponsors.json.",
+        "properties": {
+          "enable": {
+            "example": true,
+            "nullable": true,
+            "type": "boolean"
+          },
+          "id": {
+            "example": "acme-2026-10",
+            "type": "string"
+          },
+          "link": {
+            "example": "https://acme.example/?utm_source=3x-ui",
+            "type": "string"
+          },
+          "logo": {
+            "example": "/sponsors/logo/acme.png",
+            "type": "string"
+          },
+          "name": {
+            "example": "Acme VPS",
+            "type": "string"
+          },
+          "slots": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "text": {
+            "additionalProperties": {
+              "type": "string"
+            },
+            "type": "object"
+          },
+          "title": {
+            "additionalProperties": {
+              "type": "string"
+            },
+            "type": "object"
+          },
+          "until": {
+            "example": "2026-11-01T00:00:00Z",
+            "format": "date-time",
+            "type": "string"
+          }
+        },
+        "required": [
+          "id",
+          "link",
+          "name",
+          "slots",
+          "text",
+          "title",
+          "until"
+        ],
+        "type": "object"
+      },
+      "SponsorList": {
+        "description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
+        "properties": {
+          "contact": {
+            "example": "https://t.me/example",
+            "type": "string"
+          },
+          "sponsors": {
+            "items": {
+              "$ref": "#/components/schemas/Sponsor"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "sponsors"
+        ],
+        "type": "object"
+      },
       "SubBalancer": {
         "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
         "properties": {
@@ -4587,6 +4665,59 @@
         }
       }
     },
+    "/sponsors": {
+      "get": {
+        "tags": [
+          "Authentication"
+        ],
+        "summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
+        "operationId": "get_sponsors",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/SponsorList"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "contact": "https://t.me/example",
+                    "sponsors": [
+                      {
+                        "enable": true,
+                        "id": "acme-2026-10",
+                        "link": "https://acme.example/?utm_source=3x-ui",
+                        "logo": "/sponsors/logo/acme.png",
+                        "name": "Acme VPS",
+                        "slots": [
+                          ""
+                        ],
+                        "text": {},
+                        "title": {},
+                        "until": "2026-11-01T00:00:00Z"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/getTwoFactorEnable": {
       "post": {
         "tags": [

+ 131 - 0
frontend/public/openapi.json

@@ -4135,6 +4135,84 @@
         ],
         "type": "object"
       },
+      "Sponsor": {
+        "description": "Sponsor is one paid placement published in the repo's sponsors.json.",
+        "properties": {
+          "enable": {
+            "example": true,
+            "nullable": true,
+            "type": "boolean"
+          },
+          "id": {
+            "example": "acme-2026-10",
+            "type": "string"
+          },
+          "link": {
+            "example": "https://acme.example/?utm_source=3x-ui",
+            "type": "string"
+          },
+          "logo": {
+            "example": "/sponsors/logo/acme.png",
+            "type": "string"
+          },
+          "name": {
+            "example": "Acme VPS",
+            "type": "string"
+          },
+          "slots": {
+            "items": {
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "text": {
+            "additionalProperties": {
+              "type": "string"
+            },
+            "type": "object"
+          },
+          "title": {
+            "additionalProperties": {
+              "type": "string"
+            },
+            "type": "object"
+          },
+          "until": {
+            "example": "2026-11-01T00:00:00Z",
+            "format": "date-time",
+            "type": "string"
+          }
+        },
+        "required": [
+          "id",
+          "link",
+          "name",
+          "slots",
+          "text",
+          "title",
+          "until"
+        ],
+        "type": "object"
+      },
+      "SponsorList": {
+        "description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
+        "properties": {
+          "contact": {
+            "example": "https://t.me/example",
+            "type": "string"
+          },
+          "sponsors": {
+            "items": {
+              "$ref": "#/components/schemas/Sponsor"
+            },
+            "type": "array"
+          }
+        },
+        "required": [
+          "sponsors"
+        ],
+        "type": "object"
+      },
       "SubBalancer": {
         "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
         "properties": {
@@ -4587,6 +4665,59 @@
         }
       }
     },
+    "/sponsors": {
+      "get": {
+        "tags": [
+          "Authentication"
+        ],
+        "summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
+        "operationId": "get_sponsors",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {
+                      "$ref": "#/components/schemas/SponsorList"
+                    }
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "contact": "https://t.me/example",
+                    "sponsors": [
+                      {
+                        "enable": true,
+                        "id": "acme-2026-10",
+                        "link": "https://acme.example/?utm_source=3x-ui",
+                        "logo": "/sponsors/logo/acme.png",
+                        "name": "Acme VPS",
+                        "slots": [
+                          ""
+                        ],
+                        "text": {},
+                        "title": {},
+                        "until": "2026-11-01T00:00:00Z"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/getTwoFactorEnable": {
       "post": {
         "tags": [

+ 4 - 0
frontend/src/api/http-init.ts

@@ -196,6 +196,10 @@ export async function httpRequest(
   return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
 }
 
+export function withBasePath(path: string): string {
+  return basePathPrefix + path;
+}
+
 export function setupHttp(): void {
   let basePath: string | null | undefined = window.X_UI_BASE_PATH;
   if (!basePath) {

+ 23 - 0
frontend/src/api/queries/useSponsorsQuery.ts

@@ -0,0 +1,23 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { HttpUtil } from '@/utils';
+import { keys } from '@/api/queryKeys';
+import type { SponsorList } from '@/generated/types';
+
+const EMPTY: SponsorList = { sponsors: [] };
+
+async function fetchSponsors(): Promise<SponsorList> {
+  const msg = await HttpUtil.get<SponsorList>('/sponsors', undefined, { silent: true });
+  if (!msg?.success || !msg.obj) return EMPTY;
+  return { contact: msg.obj.contact, sponsors: msg.obj.sponsors ?? [] };
+}
+
+export function useSponsorsQuery() {
+  const query = useQuery({
+    queryKey: keys.sponsors(),
+    queryFn: fetchSponsors,
+    staleTime: 60 * 60 * 1000,
+    retry: false,
+  });
+  return { data: query.data ?? EMPTY, fetched: query.isFetched };
+}

+ 1 - 0
frontend/src/api/queryKeys.ts

@@ -1,4 +1,5 @@
 export const keys = {
+  sponsors: () => ['sponsors'] as const,
   server: {
     status: () => ['server', 'status'] as const,
     fail2banStatus: () => ['server', 'fail2banStatus'] as const,

+ 7 - 0
frontend/src/components/command-palette/CommandPalette.tsx

@@ -13,6 +13,7 @@ import {
   ClusterOutlined,
   CodeOutlined,
   CopyOutlined,
+  CrownOutlined,
   DashboardOutlined,
   DatabaseOutlined,
   DiscordOutlined,
@@ -418,6 +419,12 @@ export default function CommandPalette() {
         keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
         icon: <ApiOutlined />,
       },
+      {
+        path: '/sponsors',
+        title: t('menu.sponsors'),
+        keywords: ['sponsors', 'sponsor', 'partners'],
+        icon: <CrownOutlined />,
+      },
     ];
 
     pages

+ 216 - 0
frontend/src/components/sponsor/SponsorCard.css

@@ -0,0 +1,216 @@
+.sponsor-card {
+  position: relative;
+  display: flex;
+  align-items: stretch;
+  min-width: 0;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: var(--ant-border-radius-lg, 8px);
+  background: var(--bg-card, var(--ant-color-fill-quaternary));
+  transition:
+    border-color 0.2s,
+    background 0.2s;
+}
+
+.sponsor-card:hover {
+  border-color: var(--ant-color-primary);
+}
+
+.sponsor-main {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  gap: 12px;
+  min-width: 0;
+  padding: 12px 16px;
+  color: var(--ant-color-text);
+  text-decoration: none;
+}
+
+.sponsor-main:hover,
+.sponsor-main:focus-visible {
+  color: var(--ant-color-text);
+  outline: none;
+}
+
+.sponsor-logo {
+  flex: 0 0 auto;
+  width: 40px;
+  height: 40px;
+  border-radius: 8px;
+  object-fit: contain;
+  background: var(--ant-color-bg-elevated);
+}
+
+.sponsor-logo-fallback {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 600;
+  font-size: 18px;
+  color: var(--ant-color-primary);
+  background: var(--ant-color-primary-bg);
+}
+
+.sponsor-body {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+}
+
+.sponsor-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+
+.sponsor-tag {
+  flex: 0 0 auto;
+  padding: 0 6px;
+  border: 1px solid var(--ant-color-border);
+  border-radius: 4px;
+  font-size: 11px;
+  line-height: 18px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sponsor-title {
+  overflow: hidden;
+  font-weight: 600;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.sponsor-text {
+  font-size: 13px;
+  color: var(--ant-color-text-secondary);
+}
+
+.sponsor-visit {
+  flex: 0 0 auto;
+  font-size: 13px;
+  color: var(--ant-color-primary);
+  white-space: nowrap;
+}
+
+.sponsor-close {
+  flex: 0 0 auto;
+  align-self: flex-start;
+  width: 28px;
+  height: 28px;
+  margin: 6px 6px 0 0;
+  padding: 0;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: var(--ant-color-text-tertiary);
+  cursor: pointer;
+}
+
+.sponsor-close:hover,
+.sponsor-close:focus-visible {
+  color: var(--ant-color-text);
+  background: var(--ant-color-fill-tertiary);
+  outline: none;
+}
+
+[dir='rtl'] .sponsor-close {
+  margin: 6px 0 0 6px;
+}
+
+.sponsor-card-compact .sponsor-main {
+  gap: 10px;
+  padding: 8px 10px;
+}
+
+.sponsor-card-compact .sponsor-logo {
+  width: 32px;
+  height: 32px;
+}
+
+.sponsor-card-compact .sponsor-head {
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 2px;
+}
+
+.sponsor-card-compact .sponsor-title {
+  display: -webkit-box;
+  max-width: 100%;
+  font-size: 13px;
+  white-space: normal;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.sponsor-card-compact .sponsor-text {
+  display: -webkit-box;
+  overflow: hidden;
+  font-size: 12px;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.sponsor-card-compact .sponsor-close {
+  width: 22px;
+  height: 22px;
+  margin: 4px 4px 0 0;
+  font-size: 11px;
+}
+
+.sponsor-card-card {
+  height: 100%;
+}
+
+.sponsor-card-card .sponsor-main {
+  flex-direction: column;
+  align-items: flex-start;
+  padding: 16px;
+}
+
+.sponsor-card-card .sponsor-logo {
+  width: 56px;
+  height: 56px;
+}
+
+.sponsor-card-card .sponsor-visit {
+  margin-top: auto;
+}
+
+.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) {
+  .sponsor-card-banner .sponsor-main {
+    flex-wrap: wrap;
+    padding: 10px 12px;
+  }
+
+  .sponsor-card-banner .sponsor-visit {
+    flex-basis: 100%;
+    padding-inline-start: 52px;
+  }
+}
+
+.sponsor-card-card {
+  transition:
+    border-color 0.2s,
+    transform 0.2s,
+    box-shadow 0.2s;
+}
+
+.sponsor-card-card:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
+}

+ 73 - 0
frontend/src/components/sponsor/SponsorCard.stories.tsx

@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, within } from 'storybook/test';
+
+import type { Sponsor } from '@/generated/types';
+import SponsorCard from './SponsorCard';
+
+const sponsor: Sponsor = {
+  id: 'acme-2026-10',
+  name: 'Acme VPS',
+  slots: ['dashboard', 'sidebar', 'page', 'login'],
+  until: '2099-01-01T00:00:00Z',
+  title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
+  text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
+  link: 'https://acme.example/?utm_source=3x-ui',
+};
+
+const meta = {
+  title: 'Sponsor/SponsorCard',
+  component: SponsorCard,
+  tags: ['autodocs'],
+  parameters: {
+    docs: {
+      description: {
+        component:
+          'Paid sponsor placement fed by the project sponsors.json. Always labelled as a sponsor; links open in a new tab with rel="sponsored".',
+      },
+    },
+  },
+  argTypes: {
+    sponsor: { description: 'Sponsor entry from GET /sponsors.' },
+    variant: {
+      description: 'banner (dashboard), compact (sidebar/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).' },
+  },
+  args: { sponsor },
+} satisfies Meta<typeof SponsorCard>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Banner: Story = {
+  args: { variant: 'banner', onClose: () => {} },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await expect(canvas.getByText('Sponsor')).toBeInTheDocument();
+    await expect(canvas.getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer sponsored');
+  },
+};
+
+export const Compact: Story = {
+  args: { variant: 'compact', onClose: () => {} },
+  render: (args) => (
+    <div style={{ width: 204 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+};
+
+export const Card: Story = {
+  args: { variant: 'card' },
+  render: (args) => (
+    <div style={{ width: 320 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+};
+
+export const IconOnly: Story = {
+  args: { iconOnly: true },
+};

+ 101 - 0
frontend/src/components/sponsor/SponsorCard.tsx

@@ -0,0 +1,101 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { CloseOutlined, ExportOutlined } from '@ant-design/icons';
+
+import { withBasePath } from '@/api/http-init';
+import type { Sponsor } from '@/generated/types';
+import { pickLocale } from '@/lib/sponsors';
+import './SponsorCard.css';
+
+export type SponsorCardVariant = 'banner' | 'compact' | 'card';
+
+export interface SponsorCardProps {
+  sponsor: Sponsor;
+  variant?: SponsorCardVariant;
+  iconOnly?: boolean;
+  onClose?: () => void;
+}
+
+function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
+  const [failedSrc, setFailedSrc] = useState('');
+  if (sponsor.logo && failedSrc !== sponsor.logo) {
+    return (
+      <img
+        className="sponsor-logo"
+        src={withBasePath(sponsor.logo)}
+        alt=""
+        loading="lazy"
+        onError={() => setFailedSrc(sponsor.logo ?? '')}
+      />
+    );
+  }
+  return (
+    <span className="sponsor-logo sponsor-logo-fallback" aria-hidden="true">
+      {sponsor.name.slice(0, 1).toUpperCase()}
+    </span>
+  );
+}
+
+export default function SponsorCard({
+  sponsor,
+  variant = 'banner',
+  iconOnly = false,
+  onClose,
+}: SponsorCardProps) {
+  const { t, i18n } = useTranslation();
+  const lang = i18n.resolvedLanguage || i18n.language || 'en';
+  const title = pickLocale(sponsor.title, lang) || sponsor.name;
+  const text = pickLocale(sponsor.text, lang);
+  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 (
+    <div className={`sponsor-card sponsor-card-${variant}`}>
+      <a
+        className="sponsor-main"
+        href={sponsor.link}
+        target="_blank"
+        rel="noopener noreferrer sponsored"
+      >
+        <SponsorLogo sponsor={sponsor} />
+        <span className="sponsor-body" dir="auto">
+          <span className="sponsor-head">
+            <span className="sponsor-tag">{tag}</span>
+            <span className="sponsor-title">{title}</span>
+          </span>
+          {text && <span className="sponsor-text">{text}</span>}
+        </span>
+        {variant !== 'compact' && (
+          <span className="sponsor-visit">
+            {t('pages.sponsors.visit')} <ExportOutlined />
+          </span>
+        )}
+      </a>
+      {onClose && (
+        <button
+          type="button"
+          className="sponsor-close"
+          aria-label={t('close')}
+          title={t('close')}
+          onClick={onClose}
+        >
+          <CloseOutlined />
+        </button>
+      )}
+    </div>
+  );
+}

+ 60 - 0
frontend/src/components/sponsor/SponsorSlot.tsx

@@ -0,0 +1,60 @@
+import { useEffect, useState } from 'react';
+
+import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
+import {
+  dismissSponsor,
+  isSponsorDismissed,
+  sponsorsForSlot,
+  type SponsorSlot as Slot,
+} from '@/lib/sponsors';
+import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
+
+const ROTATE_MS = 30_000;
+
+interface SponsorSlotProps {
+  slot: Slot;
+  variant?: SponsorCardVariant;
+  iconOnly?: boolean;
+  rotate?: boolean;
+  className?: string;
+}
+
+export default function SponsorSlot({
+  slot,
+  variant = 'banner',
+  iconOnly,
+  rotate,
+  className,
+}: SponsorSlotProps) {
+  const { data } = useSponsorsQuery();
+  const [, setDismissTick] = useState(0);
+  const [index, setIndex] = useState(0);
+
+  // Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
+  const visible = sponsorsForSlot(data.sponsors, slot).filter(
+    (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;
+  const sponsor = visible[(rotate ? index : 0) % visible.length];
+
+  return (
+    <div className={className}>
+      <SponsorCard
+        sponsor={sponsor}
+        variant={variant}
+        iconOnly={iconOnly}
+        onClose={() => {
+          dismissSponsor(sponsor.id, slot);
+          setDismissTick((n) => n + 1);
+        }}
+      />
+    </div>
+  );
+}

+ 31 - 0
frontend/src/generated/examples.ts

@@ -1019,6 +1019,37 @@ export const EXAMPLES: Record<string, unknown> = {
     "key": "",
     "value": ""
   },
+  "Sponsor": {
+    "enable": true,
+    "id": "acme-2026-10",
+    "link": "https://acme.example/?utm_source=3x-ui",
+    "logo": "/sponsors/logo/acme.png",
+    "name": "Acme VPS",
+    "slots": [
+      ""
+    ],
+    "text": {},
+    "title": {},
+    "until": "2026-11-01T00:00:00Z"
+  },
+  "SponsorList": {
+    "contact": "https://t.me/example",
+    "sponsors": [
+      {
+        "enable": true,
+        "id": "acme-2026-10",
+        "link": "https://acme.example/?utm_source=3x-ui",
+        "logo": "/sponsors/logo/acme.png",
+        "name": "Acme VPS",
+        "slots": [
+          ""
+        ],
+        "text": {},
+        "title": {},
+        "until": "2026-11-01T00:00:00Z"
+      }
+    ]
+  },
   "SubBalancer": {
     "createdAt": 1710000000000,
     "enabled": true,

+ 78 - 0
frontend/src/generated/schemas.ts

@@ -4109,6 +4109,84 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
+  "Sponsor": {
+    "description": "Sponsor is one paid placement published in the repo's sponsors.json.",
+    "properties": {
+      "enable": {
+        "example": true,
+        "nullable": true,
+        "type": "boolean"
+      },
+      "id": {
+        "example": "acme-2026-10",
+        "type": "string"
+      },
+      "link": {
+        "example": "https://acme.example/?utm_source=3x-ui",
+        "type": "string"
+      },
+      "logo": {
+        "example": "/sponsors/logo/acme.png",
+        "type": "string"
+      },
+      "name": {
+        "example": "Acme VPS",
+        "type": "string"
+      },
+      "slots": {
+        "items": {
+          "type": "string"
+        },
+        "type": "array"
+      },
+      "text": {
+        "additionalProperties": {
+          "type": "string"
+        },
+        "type": "object"
+      },
+      "title": {
+        "additionalProperties": {
+          "type": "string"
+        },
+        "type": "object"
+      },
+      "until": {
+        "example": "2026-11-01T00:00:00Z",
+        "format": "date-time",
+        "type": "string"
+      }
+    },
+    "required": [
+      "id",
+      "link",
+      "name",
+      "slots",
+      "text",
+      "title",
+      "until"
+    ],
+    "type": "object"
+  },
+  "SponsorList": {
+    "description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
+    "properties": {
+      "contact": {
+        "example": "https://t.me/example",
+        "type": "string"
+      },
+      "sponsors": {
+        "items": {
+          "$ref": "#/components/schemas/Sponsor"
+        },
+        "type": "array"
+      }
+    },
+    "required": [
+      "sponsors"
+    ],
+    "type": "object"
+  },
   "SubBalancer": {
     "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
     "properties": {

+ 17 - 0
frontend/src/generated/types.ts

@@ -939,6 +939,23 @@ export interface Setting {
   value: string;
 }
 
+export interface Sponsor {
+  enable?: boolean | null;
+  id: string;
+  link: string;
+  logo?: string;
+  name: string;
+  slots: string[];
+  text: Record<string, string>;
+  title: Record<string, string>;
+  until: string;
+}
+
+export interface SponsorList {
+  contact?: string;
+  sponsors: Sponsor[];
+}
+
 export interface SubBalancer {
   createdAt: number;
   enabled: boolean;

+ 19 - 0
frontend/src/generated/zod.ts

@@ -998,6 +998,25 @@ export const SettingSchema = z.object({
 });
 export type Setting = z.infer<typeof SettingSchema>;
 
+export const SponsorSchema = z.object({
+  enable: z.boolean().nullable().optional(),
+  id: z.string(),
+  link: z.string(),
+  logo: z.string().optional(),
+  name: z.string(),
+  slots: z.array(z.string()),
+  text: z.record(z.string(), z.string()),
+  title: z.record(z.string(), z.string()),
+  until: z.string(),
+});
+export type Sponsor = z.infer<typeof SponsorSchema>;
+
+export const SponsorListSchema = z.object({
+  contact: z.string().optional(),
+  sponsors: z.array(z.lazy(() => SponsorSchema)),
+});
+export type SponsorList = z.infer<typeof SponsorListSchema>;
+
 export const SubBalancerSchema = z.object({
   createdAt: z.number().int(),
   enabled: z.boolean(),

+ 1 - 0
frontend/src/hooks/usePageTitle.ts

@@ -14,6 +14,7 @@ const TITLE_KEYS: Record<string, string> = {
   '/outbound': 'menu.outbounds',
   '/routing': 'menu.routing',
   '/api-docs': 'menu.apiDocs',
+  '/sponsors': 'menu.sponsors',
 };
 
 export function usePageTitle() {

+ 4 - 0
frontend/src/layouts/AppSidebar.css

@@ -247,6 +247,10 @@
   padding: 8px 8px 12px;
 }
 
+.sider-sponsor {
+  margin-bottom: 6px;
+}
+
 .sidebar-pin {
   display: inline-flex;
   align-items: center;

+ 13 - 0
frontend/src/layouts/AppSidebar.tsx

@@ -11,6 +11,7 @@ import {
   CloudServerOutlined,
   ClusterOutlined,
   CodeOutlined,
+  CrownOutlined,
   DashboardOutlined,
   DatabaseOutlined,
   DiscordOutlined,
@@ -43,6 +44,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { useAllSettings } from '@/api/queries/useAllSettings';
 import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
+import SponsorSlot from '@/components/sponsor/SponsorSlot';
 import './AppSidebar.css';
 
 const DONATE_URL = 'https://donate.sanaei.dev/';
@@ -68,6 +70,7 @@ type IconName =
   | 'cluster'
   | 'hosts'
   | 'logout'
+  | 'sponsors'
   | 'apidocs'
   | 'outbound'
   | 'routing';
@@ -82,6 +85,7 @@ const iconByName: Record<IconName, ComponentType> = {
   cluster: ClusterOutlined,
   hosts: GlobalOutlined,
   logout: LogoutOutlined,
+  sponsors: CrownOutlined,
   apidocs: ApiOutlined,
   outbound: ExportOutlined,
   routing: SwapOutlined,
@@ -232,6 +236,7 @@ export default function AppSidebar() {
       { key: '/settings', icon: 'setting', title: t('menu.settings') },
       { key: '/xray', icon: 'tool', title: t('menu.xray') },
       { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
+      { key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
       { key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
     ],
     [t],
@@ -447,6 +452,13 @@ export default function AppSidebar() {
           onClick={onMenuClick}
         />
         <div className="sider-footer">
+          <SponsorSlot
+            slot="sidebar"
+            variant="compact"
+            iconOnly={railCollapsed}
+            rotate
+            className="sider-sponsor"
+          />
           <VersionBadge version={panelVersion} collapsed={railCollapsed} />
         </div>
       </Layout.Sider>
@@ -532,6 +544,7 @@ export default function AppSidebar() {
           }}
         />
         <div className="drawer-footer">
+          <SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
           <VersionBadge version={panelVersion} />
         </div>
       </Drawer>

+ 62 - 0
frontend/src/lib/sponsors.ts

@@ -0,0 +1,62 @@
+import type { Sponsor } from '@/generated/types';
+
+export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
+
+const DISMISS_KEY = 'xui.sponsor.dismissed';
+export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
+
+export function pickLocale(map: Record<string, string> | undefined, lang: string): string {
+  if (!map) return '';
+  const short = lang.split('-')[0].toLowerCase();
+  return map[lang] || map[short] || map.en || '';
+}
+
+export function sponsorsForSlot(sponsors: Sponsor[], slot: SponsorSlot): Sponsor[] {
+  return sponsors.filter((s) => s.slots.includes(slot));
+}
+
+function readDismissed(): Record<string, number> {
+  try {
+    const parsed: unknown = JSON.parse(localStorage.getItem(DISMISS_KEY) || '{}');
+    return parsed && typeof parsed === 'object' ? (parsed as Record<string, number>) : {};
+  } catch {
+    return {};
+  }
+}
+
+export function isSponsorDismissed(id: string, slot: SponsorSlot, now = Date.now()): boolean {
+  const at = readDismissed()[`${id}:${slot}`];
+  return typeof at === 'number' && now - at < SPONSOR_DISMISS_MS;
+}
+
+export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now()) {
+  const next = Object.fromEntries(
+    Object.entries(readDismissed()).filter(([, at]) => now - at < SPONSOR_DISMISS_MS),
+  );
+  next[`${id}:${slot}`] = now;
+  try {
+    localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
+  } 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] };
+}

+ 7 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -236,6 +236,13 @@ export const sections: readonly Section[] = [
           'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
         response: '{\n  "success": true,\n  "obj": "csrf-token-string"\n}',
       },
+      {
+        method: 'GET',
+        path: '/sponsors',
+        summary:
+          'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
+        responseSchema: 'SponsorList',
+      },
       {
         method: 'POST',
         path: '/getTwoFactorEnable',

+ 3 - 0
frontend/src/pages/index/IndexPage.tsx

@@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import AppSidebar from '@/layouts/AppSidebar';
 import { LazyMount } from '@/components/utility';
+import SponsorSlot from '@/components/sponsor/SponsorSlot';
 import { setMessageInstance } from '@/utils/messageBus';
 import OverviewActionBar from './OverviewActionBar';
 import VitalTile from './VitalTile';
@@ -213,6 +214,8 @@ export default function IndexPage() {
                     onOpenVersionSwitch={() => setVersionOpen(true)}
                   />
 
+                  <SponsorSlot slot="dashboard" />
+
                   {health && (
                     <div className="ov-health" style={{ color: health.color }}>
                       <span className="ov-health-mark" />

+ 4 - 0
frontend/src/pages/login/LoginPage.css

@@ -411,3 +411,7 @@
 .submit-row {
   margin-bottom: 0;
 }
+
+.login-sponsor {
+  margin-top: 20px;
+}

+ 2 - 0
frontend/src/pages/login/LoginPage.tsx

@@ -26,6 +26,7 @@ import { FormProvider, useForm } from 'react-hook-form';
 import { HttpUtil, LanguageManager } from '@/utils';
 import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { setMessageInstance } from '@/utils/messageBus';
+import SponsorSlot from '@/components/sponsor/SponsorSlot';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
 import './LoginPage.css';
@@ -247,6 +248,7 @@ export default function LoginPage() {
                     </Form.Item>
                   </Form>
                 </FormProvider>
+                <SponsorSlot slot="login" variant="compact" className="login-sponsor" />
               </div>
             )}
           </div>

+ 142 - 0
frontend/src/pages/sponsors/SponsorsPage.css

@@ -0,0 +1,142 @@
+.sponsors-page .ant-layout,
+.sponsors-page .ant-layout-content,
+.sponsors-page .content-shell {
+  background: transparent;
+}
+
+.sponsors-page .content-area {
+  padding: 24px;
+}
+
+.sponsors-inner {
+  max-width: 1200px;
+}
+
+.sponsors-header {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 24px;
+}
+
+.sponsors-page .sponsors-title {
+  margin: 0 0 4px;
+}
+
+.sponsors-title-icon {
+  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 {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 8px;
+  height: 100%;
+  min-height: 170px;
+  padding: 16px;
+  border: 1.5px dashed var(--ant-color-border);
+  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;
+  background: var(--bg-card, transparent);
+}
+
+.sponsors-yourbrand-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 56px;
+  height: 56px;
+  border-radius: 8px;
+  font-size: 22px;
+  color: var(--ant-color-primary);
+  background: var(--ant-color-primary-bg);
+}
+
+.sponsors-yourbrand-title {
+  font-weight: 600;
+  font-size: 15px;
+  color: var(--ant-color-text);
+}
+
+.sponsors-yourbrand-text {
+  max-width: 420px;
+  margin-bottom: 4px;
+  font-size: 13px;
+  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) {
+  .sponsors-page .content-area {
+    padding: 12px;
+    padding-top: 64px;
+  }
+}

+ 176 - 0
frontend/src/pages/sponsors/SponsorsPage.tsx

@@ -0,0 +1,176 @@
+import { useMemo } from 'react';
+import type { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
+import {
+  CrownOutlined,
+  DashboardOutlined,
+  LoginOutlined,
+  MenuUnfoldOutlined,
+  PlusOutlined,
+} from '@ant-design/icons';
+
+import { useTheme } from '@/hooks/useTheme';
+import AppSidebar from '@/layouts/AppSidebar';
+import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
+import SponsorCard from '@/components/sponsor/SponsorCard';
+import { IntlUtil } from '@/utils';
+import { useDatepicker } from '@/hooks/useDatepicker';
+import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
+import './SponsorsPage.css';
+
+function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
+  const { t } = useTranslation();
+  if (!contact) return null;
+  return (
+    <Button
+      type="primary"
+      icon={<CrownOutlined />}
+      href={contact}
+      target="_blank"
+      rel="noopener noreferrer"
+      block={block}
+    >
+      {t('pages.sponsors.become')}
+    </Button>
+  );
+}
+
+function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
+  const { t } = useTranslation();
+  return (
+    <div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
+      <span className="sponsors-yourbrand-icon">
+        <PlusOutlined />
+      </span>
+      <div className="sponsors-yourbrand-title">{t('pages.sponsors.yourBrand')}</div>
+      <div className="sponsors-yourbrand-text">{t('pages.sponsors.yourBrandText')}</div>
+      <BecomeButton contact={contact} />
+    </div>
+  );
+}
+
+export default function SponsorsPage() {
+  const { t } = useTranslation();
+  const { isDark, isUltra, antdThemeConfig } = useTheme();
+  const { data, fetched } = useSponsorsQuery();
+  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 classes = ['sponsors-page'];
+    if (isDark) classes.push('is-dark');
+    if (isUltra) classes.push('is-ultra');
+    return classes.join(' ');
+  }, [isDark, isUltra]);
+
+  return (
+    <ConfigProvider theme={antdThemeConfig}>
+      <Layout className={pageClass}>
+        <AppSidebar />
+        <Layout className="content-shell">
+          <Layout.Content className="content-area">
+            <div className="sponsors-inner">
+              <div className="sponsors-header">
+                <div>
+                  <Typography.Title level={3} className="sponsors-title">
+                    <CrownOutlined className="sponsors-title-icon" /> {t('pages.sponsors.title')}
+                  </Typography.Title>
+                  <Typography.Text type="secondary">{t('pages.sponsors.intro')}</Typography.Text>
+                </div>
+                <BecomeButton contact={data.contact} />
+              </div>
+
+              <Spin spinning={!fetched} delay={200}>
+                {sponsors.length === 0 ? (
+                  fetched && <YourBrandCard contact={data.contact} large />
+                ) : (
+                  <Row gutter={[16, 16]}>
+                    {sponsors.map((sponsor) => (
+                      <Col key={sponsor.id} xs={24} sm={12} xl={8}>
+                        <SponsorCard sponsor={sponsor} variant="card" />
+                      </Col>
+                    ))}
+                    {data.contact && (
+                      <Col xs={24} sm={12} xl={8}>
+                        <YourBrandCard contact={data.contact} />
+                      </Col>
+                    )}
+                  </Row>
+                )}
+              </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>
+          </Layout.Content>
+        </Layout>
+      </Layout>
+    </ConfigProvider>
+  );
+}

+ 2 - 0
frontend/src/routes.tsx

@@ -13,6 +13,7 @@ const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
 const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
 const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
 const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
+const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
 
 function withSuspense(node: React.ReactNode) {
   return (
@@ -51,6 +52,7 @@ const routes: RouteObject[] = [
       { path: 'outbound', element: withSuspense(<XrayPage />) },
       { path: 'routing', element: withSuspense(<XrayPage />) },
       { path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
+      { path: 'sponsors', element: withSuspense(<SponsorsPage />) },
     ],
   },
 ];

+ 6 - 3
frontend/src/styles/page-shell.css

@@ -5,7 +5,8 @@
 .settings-page,
 .nodes-page,
 .groups-page,
-.api-docs-page {
+.api-docs-page,
+.sponsors-page {
   --bg-page: #e6e8ec;
   --bg-card: #ffffff;
   min-height: 100vh;
@@ -19,7 +20,8 @@
 .settings-page.is-dark,
 .nodes-page.is-dark,
 .groups-page.is-dark,
-.api-docs-page.is-dark {
+.api-docs-page.is-dark,
+.sponsors-page.is-dark {
   --bg-page: #1a1b1f;
   --bg-card: #23252b;
 }
@@ -31,7 +33,8 @@
 .settings-page.is-dark.is-ultra,
 .nodes-page.is-dark.is-ultra,
 .groups-page.is-dark.is-ultra,
-.api-docs-page.is-dark.is-ultra {
+.api-docs-page.is-dark.is-ultra,
+.sponsors-page.is-dark.is-ultra {
   --bg-page: #000;
   --bg-card: #101013;
 }

+ 98 - 0
frontend/src/test/sponsors.test.tsx

@@ -0,0 +1,98 @@
+import { afterEach, describe, expect, test } from 'vitest';
+
+import {
+  SPONSOR_DISMISS_MS,
+  dismissSponsor,
+  isSponsorDismissed,
+  pickLocale,
+  placementStatus,
+} from '@/lib/sponsors';
+
+afterEach(() => {
+  localStorage.clear();
+});
+
+describe('pickLocale', () => {
+  const map = { en: 'Hello', fa: 'سلام', 'zh-TW': '哈囉' };
+
+  test.each([
+    ['fa-IR', 'سلام'],
+    ['zh-TW', '哈囉'],
+    ['zh-CN', 'Hello'],
+    ['ru-RU', 'Hello'],
+  ])('%s resolves to %s', (lang, want) => {
+    expect(pickLocale(map, lang)).toBe(want);
+  });
+
+  test('missing map yields empty string', () => {
+    expect(pickLocale(undefined, 'en-US')).toBe('');
+  });
+});
+
+describe('sponsor dismissal', () => {
+  const t0 = 1_800_000_000_000;
+
+  test('hides only the dismissed slot until the window elapses', () => {
+    dismissSponsor('acme', 'sidebar', t0);
+    expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
+    expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
+    expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
+  });
+
+  test('prunes expired entries on the next dismiss', () => {
+    dismissSponsor('old', 'login', t0);
+    dismissSponsor('new', 'login', t0 + SPONSOR_DISMISS_MS);
+    const stored = JSON.parse(localStorage.getItem('xui.sponsor.dismissed') || '{}');
+    expect(Object.keys(stored)).toEqual(['new:login']);
+  });
+
+  test('corrupt storage is treated as nothing dismissed', () => {
+    localStorage.setItem('xui.sponsor.dismissed', 'not json');
+    expect(isSponsorDismissed('acme', 'sidebar', 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,
+    });
+  });
+});

+ 1 - 1
frontend/vite.config.js

@@ -286,7 +286,7 @@ export default defineConfig({
     port: 5173,
     strictPort: true,
     proxy: {
-      '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
+      '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|sponsors|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
       '^/$': makeBackendProxy(BACKEND_TARGET),
       '^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
       '^/(?:[^/]+/)?ws$': {

+ 26 - 0
internal/web/controller/index.go

@@ -6,6 +6,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
@@ -28,6 +29,7 @@ type IndexController struct {
 
 	settingService service.SettingService
 	userService    panel.UserService
+	panelService   panel.PanelService
 	tgbot          tgbot.Tgbot
 }
 
@@ -42,12 +44,36 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
 func (a *IndexController) initRouter(g *gin.RouterGroup) {
 	g.GET("/", a.index)
 	g.GET("/csrf-token", a.csrfToken)
+	g.GET("/sponsors", a.sponsors)
+	g.GET("/sponsors/logo/:name", a.sponsorLogo)
 
 	g.POST("/login", middleware.CSRFMiddleware(), a.login)
 	g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
 	g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
 }
 
+// sponsors is public so the login page can render its slot; failures stay silent.
+func (a *IndexController) sponsors(c *gin.Context) {
+	list, err := a.panelService.GetSponsors()
+	if err != nil {
+		logger.Debug("sponsors fetch failed:", err)
+		c.JSON(http.StatusOK, entity.Msg{Success: false})
+		return
+	}
+	jsonObj(c, list, nil)
+}
+
+func (a *IndexController) sponsorLogo(c *gin.Context) {
+	data, contentType, err := a.panelService.GetSponsorLogo(c.Param("name"))
+	if err != nil {
+		logger.Debug("sponsor logo failed:", err)
+		c.Status(http.StatusNotFound)
+		return
+	}
+	c.Header("Cache-Control", "public, max-age=3600")
+	c.Data(http.StatusOK, contentType, data)
+}
+
 // index handles the root route, redirecting logged-in users to the panel or showing the login page.
 func (a *IndexController) index(c *gin.Context) {
 	if session.IsLogin(c) {

+ 5 - 4
internal/web/routes_contract_test.go

@@ -23,16 +23,17 @@ and an entry for a removed route documents an endpoint that 404s. This test
 constructs the real router and diffs it against the registry both ways.
 
 Scope: everything under /panel/api/ plus the session-auth surface the
-registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
-/ws). SPA page routes are UI, not API, and stay out; registry paths that
-start with "/{" describe the standalone subscription server, which this
-engine does not serve.
+registry also documents (/login, /logout, /csrf-token, /sponsors,
+/getTwoFactorEnable, /ws). SPA page routes are UI, not API, and stay out;
+registry paths that start with "/{" describe the standalone subscription
+server, which this engine does not serve.
 */
 
 var contractExtraRoutes = map[string]bool{
 	"POST /login":              true,
 	"POST /logout":             true,
 	"GET /csrf-token":          true,
+	"GET /sponsors":            true,
 	"POST /getTwoFactorEnable": true,
 	"GET /ws":                  true,
 }

+ 246 - 0
internal/web/service/panel/sponsor.go

@@ -0,0 +1,246 @@
+package panel
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+	"slices"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+)
+
+// Sponsor is one paid placement published in the repo's sponsors.json.
+type Sponsor struct {
+	ID     string            `json:"id" example:"acme-2026-10"`
+	Name   string            `json:"name" example:"Acme VPS"`
+	Enable *bool             `json:"enable,omitempty" example:"true"`
+	Slots  []string          `json:"slots"`
+	Until  time.Time         `json:"until" example:"2026-11-01T00:00:00Z"`
+	Logo   string            `json:"logo,omitempty" example:"/sponsors/logo/acme.png"`
+	Title  map[string]string `json:"title"`
+	Text   map[string]string `json:"text"`
+	Link   string            `json:"link" example:"https://acme.example/?utm_source=3x-ui"`
+}
+
+// SponsorList is the active sponsor set plus the contact link for new sponsors.
+type SponsorList struct {
+	Contact  string    `json:"contact,omitempty" example:"https://t.me/example"`
+	Sponsors []Sponsor `json:"sponsors"`
+}
+
+const (
+	sponsorsTTL      = time.Hour
+	sponsorsErrTTL   = 10 * time.Minute
+	maxSponsorsBytes = 256 << 10
+	maxLogoBytes     = 256 << 10
+	maxSidebarSlots  = 3
+	localSponsorsDir = "../sponsors/3X"
+	sponsorLogoPath  = "/sponsors/logo/"
+)
+
+// ErrSponsorLogoUnknown rejects logo names not used by an active sponsor.
+var ErrSponsorLogoUnknown = errors.New("unknown sponsor logo")
+
+type sponsorLogo struct {
+	data        []byte
+	contentType string
+	err         error
+	retryAt     time.Time
+}
+
+var (
+	sponsorsURL     = "https://sponsors.sanaei.dev/3X/sponsors.json"
+	sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/"
+	sponsorNow      = time.Now
+
+	// 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)$`)
+	sponsorSlots  = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
+
+	sponsorsMu      sync.Mutex
+	sponsorsRaw     *SponsorList
+	sponsorsErr     error
+	sponsorsRetryAt time.Time
+
+	logosMu sync.Mutex
+	logos   = map[string]sponsorLogo{}
+)
+
+// GetSponsors returns the currently active sponsors. The remote file is cached,
+// but expiry is re-checked on every call so a slot ends exactly at Until.
+func (s *PanelService) GetSponsors() (*SponsorList, error) {
+	raw, err := cachedSponsors()
+	if err != nil {
+		return nil, err
+	}
+	return activeSponsors(raw, sponsorNow()), nil
+}
+
+func cachedSponsors() (*SponsorList, error) {
+	sponsorsMu.Lock()
+	defer sponsorsMu.Unlock()
+	now := sponsorNow()
+	if !config.IsDebug() && now.Before(sponsorsRetryAt) {
+		return sponsorsRaw, sponsorsErr
+	}
+	list, err := fetchSponsors()
+	switch {
+	case err == nil:
+		sponsorsRaw, sponsorsErr, sponsorsRetryAt = list, nil, now.Add(sponsorsTTL)
+	case sponsorsRaw != nil:
+		// An upstream blip keeps the last good list, so paid slots do not blink out.
+		logger.Debug("sponsors refresh failed, keeping last list:", err)
+		sponsorsRetryAt = now.Add(sponsorsErrTTL)
+	default:
+		sponsorsErr, sponsorsRetryAt = err, now.Add(sponsorsErrTTL)
+	}
+	return sponsorsRaw, sponsorsErr
+}
+
+// GetSponsorLogo returns the image bytes for a logo of a currently active sponsor.
+func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
+	sponsors, err := s.GetSponsors()
+	if err != nil {
+		return nil, "", err
+	}
+	if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) {
+		return nil, "", ErrSponsorLogoUnknown
+	}
+	logosMu.Lock()
+	defer logosMu.Unlock()
+	now := sponsorNow()
+	l, ok := logos[name]
+	if ok && !config.IsDebug() && now.Before(l.retryAt) {
+		return l.data, l.contentType, l.err
+	}
+	// Failures are cached too: this route is public and each miss is an outbound fetch.
+	data, contentType, err := fetchSponsorLogo(name)
+	switch {
+	case err == nil:
+		l = sponsorLogo{data: data, contentType: contentType, retryAt: now.Add(sponsorsTTL)}
+	case l.data != nil:
+		l.retryAt = now.Add(sponsorsErrTTL)
+	default:
+		l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)}
+	}
+	logos[name] = l
+	return l.data, l.contentType, l.err
+}
+
+func fetchSponsorLogo(name string) ([]byte, string, error) {
+	data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
+	if err != nil {
+		return nil, "", err
+	}
+	contentType := http.DetectContentType(data)
+	switch contentType {
+	case "image/png", "image/webp", "image/jpeg":
+		return data, contentType, nil
+	default:
+		return nil, "", fmt.Errorf("sponsor logo %s has content type %s", name, contentType)
+	}
+}
+
+// readSponsorSource reads a sibling checkout of MHSanaei/sponsors under XUI_DEBUG so
+// sponsor edits can be previewed locally before they are pushed.
+func readSponsorSource(url, localPath string, limit int) ([]byte, error) {
+	if !config.IsDebug() {
+		return fetchLimited(url, limit)
+	}
+	data, err := os.ReadFile(localPath)
+	if err != nil {
+		return nil, err
+	}
+	if len(data) > limit {
+		return nil, fmt.Errorf("%s exceeds %d bytes", localPath, limit)
+	}
+	return data, nil
+}
+
+func fetchLimited(url string, limit int) ([]byte, error) {
+	client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
+	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("fetch %s returned status %d", url, resp.StatusCode)
+	}
+	body, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit)+1))
+	if err != nil {
+		return nil, err
+	}
+	if len(body) > limit {
+		return nil, fmt.Errorf("%s exceeds %d bytes", url, limit)
+	}
+	return body, nil
+}
+
+func fetchSponsors() (*SponsorList, error) {
+	body, err := readSponsorSource(sponsorsURL, filepath.Join(localSponsorsDir, "sponsors.json"), maxSponsorsBytes)
+	if err != nil {
+		return nil, err
+	}
+	var list SponsorList
+	if err := json.Unmarshal(body, &list); err != nil {
+		return nil, err
+	}
+	return &list, nil
+}
+
+func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
+	out := &SponsorList{Sponsors: []Sponsor{}}
+	if raw == nil {
+		return out
+	}
+	if strings.HasPrefix(raw.Contact, "https://") {
+		out.Contact = raw.Contact
+	}
+	sidebarTaken := 0
+	for _, sp := range raw.Sponsors {
+		// A missing enable counts as on, so a forgotten field never hides a paid slot.
+		disabled := sp.Enable != nil && !*sp.Enable
+		if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
+			continue
+		}
+		// A bad logo name drops only the logo; the paid slot still renders with its initial.
+		if sponsorLogoRe.MatchString(sp.Logo) {
+			sp.Logo = sponsorLogoPath + sp.Logo
+		} else {
+			sp.Logo = ""
+		}
+		slots := make([]string, 0, len(sp.Slots))
+		for _, slot := range sp.Slots {
+			// The sidebar rotates, so capping it keeps each paid card on screen long enough.
+			if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
+				continue
+			}
+			slots = append(slots, slot)
+		}
+		if len(slots) == 0 {
+			continue
+		}
+		if slices.Contains(slots, "sidebar") {
+			sidebarTaken++
+		}
+		sp.Slots = slots
+		out.Sponsors = append(out.Sponsors, sp)
+	}
+	return out
+}

+ 334 - 0
internal/web/service/panel/sponsor_test.go

@@ -0,0 +1,334 @@
+package panel
+
+import (
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"slices"
+	"strings"
+	"sync/atomic"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+var sponsorTestNow = time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)
+
+func validSponsor() Sponsor {
+	return Sponsor{
+		ID:    "acme",
+		Name:  "Acme",
+		Slots: []string{"dashboard", "sidebar"},
+		Until: sponsorTestNow.Add(24 * time.Hour),
+		Logo:  "acme.png",
+		Link:  "https://acme.example/",
+	}
+}
+
+func TestActiveSponsorsFilters(t *testing.T) {
+	cases := []struct {
+		name   string
+		mutate func(*Sponsor)
+		kept   bool
+	}{
+		{"valid", func(*Sponsor) {}, true},
+		{"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false},
+		{"enable false with future until", func(s *Sponsor) { s.Enable = new(false) }, false},
+		{"enable true without until", func(s *Sponsor) { s.Enable, s.Until = new(true), time.Time{} }, false},
+		{"enable true with future until", func(s *Sponsor) { s.Enable = new(true) }, true},
+		{"missing id", func(s *Sponsor) { s.ID = "" }, false},
+		{"http link", func(s *Sponsor) { s.Link = "http://acme.example/" }, false},
+		{"javascript link", func(s *Sponsor) { s.Link = "javascript:alert(1)" }, false},
+		{"only unknown slots", func(s *Sponsor) { s.Slots = []string{"subpage"} }, false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			sp := validSponsor()
+			tc.mutate(&sp)
+			got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
+			if kept := len(got.Sponsors) == 1; kept != tc.kept {
+				t.Fatalf("kept = %v, want %v", kept, tc.kept)
+			}
+		})
+	}
+}
+
+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) {
+	cases := []struct{ logo, want string }{
+		{"acme.png", "/sponsors/logo/acme.png"},
+		{"VPS.png", "/sponsors/logo/VPS.png"},
+		{"../x.png", ""},
+		{"https://evil.example/x.png", ""},
+		{"..png", ""},
+		{"logo.svg", ""},
+		{"", ""},
+	}
+	for _, tc := range cases {
+		t.Run(tc.logo, func(t *testing.T) {
+			sp := validSponsor()
+			sp.Logo = tc.logo
+			got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
+			if len(got.Sponsors) != 1 {
+				t.Fatalf("sponsor dropped for logo %q; want it kept", tc.logo)
+			}
+			if got.Sponsors[0].Logo != tc.want {
+				t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, tc.want)
+			}
+		})
+	}
+}
+
+func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
+	sp := validSponsor()
+	sp.Slots = []string{"subpage", "login"}
+	got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
+	if len(got.Sponsors) != 1 {
+		t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
+	}
+	if want := "/sponsors/logo/acme.png"; got.Sponsors[0].Logo != want {
+		t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, want)
+	}
+	if s := got.Sponsors[0].Slots; len(s) != 1 || s[0] != "login" {
+		t.Errorf("slots = %v, want [login]", s)
+	}
+	if got.Contact != "" {
+		t.Errorf("contact = %q, want empty for non-https", got.Contact)
+	}
+}
+
+func setupSponsorServer(t *testing.T, body string) *atomic.Int32 {
+	t.Helper()
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := database.InitDB(config.GetDBPath()); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	var hits atomic.Int32
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		hits.Add(1)
+		isLogo := strings.HasPrefix(r.URL.Path, "/media/")
+		if (isLogo && failSponsorLogos.Load()) || (!isLogo && failSponsorList.Load()) {
+			w.WriteHeader(http.StatusBadGateway)
+			return
+		}
+		switch r.URL.Path {
+		case "/media/acme.png":
+			_, _ = w.Write(pngMagic)
+		case "/media/fake.png":
+			_, _ = w.Write([]byte("<svg onload=alert(1)>"))
+		default:
+			_, _ = w.Write([]byte(body))
+		}
+	}))
+	t.Cleanup(srv.Close)
+
+	prevURL, prevBase, prevNow := sponsorsURL, sponsorLogoBase, sponsorNow
+	sponsorsURL, sponsorLogoBase = srv.URL+"/sponsors.json", srv.URL+"/media/"
+	resetSponsorCache()
+	t.Cleanup(func() {
+		sponsorsURL, sponsorLogoBase, sponsorNow = prevURL, prevBase, prevNow
+		failSponsorList.Store(false)
+		failSponsorLogos.Store(false)
+		resetSponsorCache()
+	})
+	return &hits
+}
+
+var failSponsorList, failSponsorLogos atomic.Bool
+
+func TestGetSponsorsKeepsLastListWhenRefreshFails(t *testing.T) {
+	hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
+		"until":"2099-01-01T00:00:00Z","link":"https://acme.example/"}]}`)
+	now := sponsorTestNow
+	sponsorNow = func() time.Time { return now }
+	svc := &PanelService{}
+	if got, err := svc.GetSponsors(); err != nil || len(got.Sponsors) != 1 {
+		t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
+	}
+
+	failSponsorList.Store(true)
+	now = now.Add(sponsorsTTL)
+	got, err := svc.GetSponsors()
+	if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != "acme" {
+		t.Fatalf("after failed refresh = %+v, %v; want the last good sponsor kept", got, err)
+	}
+	now = now.Add(sponsorsErrTTL - time.Second)
+	if _, err := svc.GetSponsors(); err != nil {
+		t.Fatal(err)
+	}
+	if n := hits.Load(); n != 2 {
+		t.Fatalf("remote hits = %d, want 2 (failed refresh retried only after sponsorsErrTTL)", n)
+	}
+}
+
+func TestGetSponsorLogoCachesFailuresAndKeepsLastImage(t *testing.T) {
+	hits := setupSponsorServer(t, `{"sponsors":[
+		{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
+		{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"}]}`)
+	now := sponsorTestNow
+	sponsorNow = func() time.Time { return now }
+	svc := &PanelService{}
+
+	const wantErr = "sponsor logo fake.png has content type text/plain; charset=utf-8"
+	for range 2 {
+		if _, _, err := svc.GetSponsorLogo("fake.png"); err == nil || err.Error() != wantErr {
+			t.Fatalf("fake.png err = %v, want %q", err, wantErr)
+		}
+	}
+	if n := hits.Load(); n != 2 {
+		t.Fatalf("remote hits = %d, want 2 (list + one fake.png fetch; the failure must be cached)", n)
+	}
+
+	if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
+		t.Fatal(err)
+	}
+	failSponsorLogos.Store(true)
+	now = now.Add(sponsorsTTL)
+	data, ctype, err := svc.GetSponsorLogo("acme.png")
+	if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
+		t.Fatalf("acme.png after failed refresh = %q, %q, %v; want the last good image", data, ctype, err)
+	}
+}
+
+func resetSponsorCache() {
+	sponsorsMu.Lock()
+	defer sponsorsMu.Unlock()
+	sponsorsRaw, sponsorsErr, sponsorsRetryAt = nil, nil, time.Time{}
+	logosMu.Lock()
+	defer logosMu.Unlock()
+	logos = map[string]sponsorLogo{}
+}
+
+var pngMagic = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
+
+func TestGetSponsorLogo(t *testing.T) {
+	hits := setupSponsorServer(t, `{"sponsors":[
+		{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
+		{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"},
+		{"id":"old","slots":["page"],"until":"2000-01-01T00:00:00Z","link":"https://o.example/","logo":"old.png"}]}`)
+	svc := &PanelService{}
+
+	data, ctype, err := svc.GetSponsorLogo("acme.png")
+	if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
+		t.Fatalf("acme.png = %q, %q, %v; want png bytes", data, ctype, err)
+	}
+	before := hits.Load()
+	if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
+		t.Fatal(err)
+	}
+	if hits.Load() != before {
+		t.Fatalf("second logo fetch hit the remote; want cached")
+	}
+
+	for _, name := range []string{"old.png", "other.png", "../sponsors.json"} {
+		if _, _, err := svc.GetSponsorLogo(name); !errors.Is(err, ErrSponsorLogoUnknown) {
+			t.Errorf("%s: err = %v, want ErrSponsorLogoUnknown", name, err)
+		}
+	}
+	_, _, err = svc.GetSponsorLogo("fake.png")
+	if want := "sponsor logo fake.png has content type text/plain; charset=utf-8"; err == nil || err.Error() != want {
+		t.Errorf("fake.png: err = %v, want %q", err, want)
+	}
+}
+
+func TestGetSponsorsCachesAndExpiresWhileCached(t *testing.T) {
+	hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
+		"until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`)
+	now := sponsorTestNow
+	sponsorNow = func() time.Time { return now }
+	svc := &PanelService{}
+
+	got, err := svc.GetSponsors()
+	if err != nil || len(got.Sponsors) != 1 {
+		t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
+	}
+	now = now.Add(45 * time.Minute)
+	got, err = svc.GetSponsors()
+	if err != nil || len(got.Sponsors) != 0 {
+		t.Fatalf("after until = %+v, %v; want 0 sponsors", got, err)
+	}
+	if n := hits.Load(); n != 1 {
+		t.Fatalf("remote hits = %d, want 1 (cached within TTL)", n)
+	}
+	now = now.Add(sponsorsTTL)
+	if _, err := svc.GetSponsors(); err != nil {
+		t.Fatal(err)
+	}
+	if n := hits.Load(); n != 2 {
+		t.Fatalf("remote hits after TTL = %d, want 2", n)
+	}
+}
+
+func TestGetSponsorsRejectsOversizeBody(t *testing.T) {
+	setupSponsorServer(t, `{"contact":"`+strings.Repeat("a", maxSponsorsBytes)+`"}`)
+	_, err := (&PanelService{}).GetSponsors()
+	want := sponsorsURL + " exceeds 262144 bytes"
+	if err == nil || err.Error() != want {
+		t.Fatalf("err = %v, want %q", err, want)
+	}
+}
+
+func TestGetSponsorsDebugReadsLocalCheckout(t *testing.T) {
+	hits := setupSponsorServer(t, `{"sponsors":[]}`)
+	t.Setenv("XUI_DEBUG", "true")
+	root := t.TempDir()
+	local := filepath.Join(root, "sponsors", "3X")
+	if err := os.MkdirAll(local, 0o700); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Mkdir(filepath.Join(root, "3x-ui"), 0o700); err != nil {
+		t.Fatal(err)
+	}
+	t.Chdir(filepath.Join(root, "3x-ui"))
+	write := func(id string) {
+		t.Helper()
+		body := `{"sponsors":[{"id":"` + id + `","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/"}]}`
+		if err := os.WriteFile(filepath.Join(local, "sponsors.json"), []byte(body), 0o600); err != nil {
+			t.Fatal(err)
+		}
+	}
+	svc := &PanelService{}
+	for _, id := range []string{"first", "edited"} {
+		write(id)
+		got, err := svc.GetSponsors()
+		if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != id {
+			t.Fatalf("GetSponsors() = %+v, %v; want local sponsor %q", got, err, id)
+		}
+	}
+	if n := hits.Load(); n != 0 {
+		t.Fatalf("remote hits = %d, want 0 in debug mode", n)
+	}
+}

+ 22 - 0
internal/web/translation/ar-EG.json

@@ -120,6 +120,7 @@
     "outbounds": "الصادرات",
     "apiDocs": "توثيق API",
     "donate": "تبرع",
+    "sponsors": "الرعاة",
     "hosts": "المضيفات",
     "docs": "التوثيق",
     "openMenu": "فتح القائمة",
@@ -2256,6 +2257,27 @@
         "badTag": "وسم غير صالح",
         "badVlessRoute": "أدخل رقماً واحداً بين 0 و65535"
       }
+    },
+    "sponsors": {
+      "title": "الرعاة",
+      "intro": "شركات تدعم تطوير 3X-UI. تُفتح روابط الرعاة في علامة تبويب جديدة.",
+      "become": "كن راعيًا",
+      "tag": "راعٍ",
+      "visit": "زيارة",
+      "yourBrand": "علامتك التجارية هنا",
+      "yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم.",
+      "placements": "المواضع المتاحة",
+      "placementDashboard": "لافتة لوحة المعلومات",
+      "placementDashboardDesc": "موضع مميز أعلى لوحة المعلومات، راعٍ واحد في كل مرة.",
+      "placementSidebar": "بطاقة الشريط الجانبي",
+      "placementSidebarDesc": "تظهر في كل صفحات اللوحة وتتناوب بين الرعاة.",
+      "placementLogin": "صفحة تسجيل الدخول",
+      "placementLoginDesc": "يراها المسؤول عند كل تسجيل دخول.",
+      "placementPage": "صفحة الرعاة",
+      "placementPageDesc": "مدرجة هنا مع الشعار والوصف والرابط.",
+      "available": "متاح",
+      "takenUntil": "محجوز حتى {date}",
+      "activeCount": "{count} نشط"
     }
   },
   "tgbot": {

+ 22 - 0
internal/web/translation/en-US.json

@@ -121,6 +121,7 @@
     "outbounds": "Outbounds",
     "apiDocs": "API Docs",
     "donate": "Donate",
+    "sponsors": "Sponsors",
     "docs": "Documentation",
     "openMenu": "Open menu",
     "pinSidebar": "Pin sidebar",
@@ -2256,6 +2257,27 @@
       },
       "defaultOutbound": "Default Outbound",
       "defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)."
+    },
+    "sponsors": {
+      "title": "Sponsors",
+      "intro": "Companies that support 3X-UI development. Sponsored links open in a new tab.",
+      "become": "Become a sponsor",
+      "tag": "Sponsor",
+      "visit": "Visit",
+      "yourBrand": "Your brand here",
+      "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": {

+ 22 - 0
internal/web/translation/es-ES.json

@@ -120,6 +120,7 @@
     "outbounds": "Salidas",
     "apiDocs": "Documentación de la API",
     "donate": "Donar",
+    "sponsors": "Patrocinadores",
     "hosts": "Hosts",
     "docs": "Documentación",
     "openMenu": "Abrir menú",
@@ -2256,6 +2257,27 @@
         "badTag": "Etiqueta no válida",
         "badVlessRoute": "Introduce un único número entre 0 y 65535"
       }
+    },
+    "sponsors": {
+      "title": "Patrocinadores",
+      "intro": "Empresas que apoyan el desarrollo de 3X-UI. Los enlaces patrocinados se abren en una pestaña nueva.",
+      "become": "Hazte patrocinador",
+      "tag": "Patrocinador",
+      "visit": "Visitar",
+      "yourBrand": "Tu marca aquí",
+      "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": {

+ 22 - 0
internal/web/translation/fa-IR.json

@@ -120,6 +120,7 @@
     "outbounds": "خروجی‌ها",
     "apiDocs": "مستندات API",
     "donate": "حمایت مالی",
+    "sponsors": "اسپانسرها",
     "hosts": "میزبان‌ها",
     "docs": "مستندات",
     "openMenu": "باز کردن منو",
@@ -2256,6 +2257,27 @@
         "badTag": "برچسب نامعتبر",
         "badVlessRoute": "یک عدد بین 0 تا 65535 وارد کنید"
       }
+    },
+    "sponsors": {
+      "title": "اسپانسرها",
+      "intro": "شرکت‌هایی که از توسعه‌ی 3X-UI حمایت می‌کنند. لینک‌های اسپانسر در تب جدید باز می‌شوند.",
+      "become": "اسپانسر شوید",
+      "tag": "اسپانسر",
+      "visit": "مشاهده",
+      "yourBrand": "جای برند شما",
+      "yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز می‌کنند.",
+      "placements": "جایگاه‌های موجود",
+      "placementDashboard": "بنر داشبورد",
+      "placementDashboardDesc": "جایگاه ویژه بالای صفحه‌ی داشبورد، هر بار فقط یک اسپانسر.",
+      "placementSidebar": "کارت سایدبار",
+      "placementSidebarDesc": "در همه‌ی صفحات پنل نمایش داده می‌شود و بین اسپانسرها چرخش دارد.",
+      "placementLogin": "صفحه‌ی ورود",
+      "placementLoginDesc": "ادمین در هر بار ورود آن را می‌بیند.",
+      "placementPage": "صفحه‌ی اسپانسرها",
+      "placementPageDesc": "همراه با لوگو، توضیحات و لینک در همین صفحه.",
+      "available": "خالی",
+      "takenUntil": "پر شده تا {date}",
+      "activeCount": "{count} فعال"
     }
   },
   "tgbot": {

+ 22 - 0
internal/web/translation/id-ID.json

@@ -120,6 +120,7 @@
     "outbounds": "Outbound",
     "apiDocs": "Dokumentasi API",
     "donate": "Donasi",
+    "sponsors": "Sponsor",
     "hosts": "Host",
     "docs": "Dokumentasi",
     "openMenu": "Buka menu",
@@ -2256,6 +2257,27 @@
         "badTag": "Tag tidak valid",
         "badVlessRoute": "Masukkan satu angka antara 0 dan 65535"
       }
+    },
+    "sponsors": {
+      "title": "Sponsor",
+      "intro": "Perusahaan yang mendukung pengembangan 3X-UI. Tautan sponsor dibuka di tab baru.",
+      "become": "Jadi sponsor",
+      "tag": "Sponsor",
+      "visit": "Kunjungi",
+      "yourBrand": "Merek Anda di sini",
+      "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": {

+ 22 - 0
internal/web/translation/ja-JP.json

@@ -120,6 +120,7 @@
     "outbounds": "アウトバウンド",
     "apiDocs": "API ドキュメント",
     "donate": "寄付",
+    "sponsors": "スポンサー",
     "hosts": "ホスト",
     "docs": "ドキュメント",
     "openMenu": "メニューを開く",
@@ -2256,6 +2257,27 @@
         "badTag": "無効なタグ",
         "badVlessRoute": "0〜65535 の単一の数値を入力してください"
       }
+    },
+    "sponsors": {
+      "title": "スポンサー",
+      "intro": "3X-UI の開発を支援している企業です。スポンサーリンクは新しいタブで開きます。",
+      "become": "スポンサーになる",
+      "tag": "スポンサー",
+      "visit": "訪問",
+      "yourBrand": "ここにあなたのブランドを",
+      "yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。",
+      "placements": "掲載枠",
+      "placementDashboard": "ダッシュボードバナー",
+      "placementDashboardDesc": "ダッシュボード上部のプレミアム枠。一度に 1 社のみ。",
+      "placementSidebar": "サイドバーカード",
+      "placementSidebarDesc": "パネルの全ページに表示され、スポンサー間でローテーションします。",
+      "placementLogin": "ログインページ",
+      "placementLoginDesc": "管理者がログインするたびに表示されます。",
+      "placementPage": "スポンサーページ",
+      "placementPageDesc": "ロゴ、説明、リンク付きでここに掲載されます。",
+      "available": "空きあり",
+      "takenUntil": "{date} まで予約済み",
+      "activeCount": "{count} 件掲載中"
     }
   },
   "tgbot": {

+ 22 - 0
internal/web/translation/pt-BR.json

@@ -120,6 +120,7 @@
     "outbounds": "Saídas",
     "apiDocs": "Documentação da API",
     "donate": "Doar",
+    "sponsors": "Patrocinadores",
     "hosts": "Hosts",
     "docs": "Documentação",
     "openMenu": "Abrir menu",
@@ -2256,6 +2257,27 @@
         "badTag": "Tag inválida",
         "badVlessRoute": "Insira um único número entre 0 e 65535"
       }
+    },
+    "sponsors": {
+      "title": "Patrocinadores",
+      "intro": "Empresas que apoiam o desenvolvimento do 3X-UI. Links patrocinados abrem em uma nova aba.",
+      "become": "Seja um patrocinador",
+      "tag": "Patrocinador",
+      "visit": "Visitar",
+      "yourBrand": "Sua marca aqui",
+      "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": {

+ 22 - 0
internal/web/translation/ru-RU.json

@@ -120,6 +120,7 @@
     "outbounds": "Исходящие",
     "apiDocs": "Документация API",
     "donate": "Поддержать",
+    "sponsors": "Спонсоры",
     "hosts": "Хосты",
     "docs": "Документация",
     "openMenu": "Открыть меню",
@@ -2256,6 +2257,27 @@
         "badTag": "Недопустимый тег",
         "badVlessRoute": "Введите одно число от 0 до 65535"
       }
+    },
+    "sponsors": {
+      "title": "Спонсоры",
+      "intro": "Компании, поддерживающие разработку 3X-UI. Спонсорские ссылки открываются в новой вкладке.",
+      "become": "Стать спонсором",
+      "tag": "Спонсор",
+      "visit": "Перейти",
+      "yourBrand": "Здесь может быть ваш бренд",
+      "yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI.",
+      "placements": "Доступные места",
+      "placementDashboard": "Баннер на главной",
+      "placementDashboardDesc": "Премиум-место вверху панели, один спонсор за раз.",
+      "placementSidebar": "Карточка в боковой панели",
+      "placementSidebarDesc": "Показывается на всех страницах панели, спонсоры чередуются.",
+      "placementLogin": "Страница входа",
+      "placementLoginDesc": "Администратор видит её при каждом входе.",
+      "placementPage": "Страница спонсоров",
+      "placementPageDesc": "Размещение здесь с логотипом, описанием и ссылкой.",
+      "available": "Свободно",
+      "takenUntil": "Занято до {date}",
+      "activeCount": "Активных: {count}"
     }
   },
   "tgbot": {

+ 22 - 0
internal/web/translation/tr-TR.json

@@ -120,6 +120,7 @@
     "outbounds": "Giden Bağlantılar",
     "apiDocs": "API Belgeleri",
     "donate": "Bağış Yap",
+    "sponsors": "Sponsorlar",
     "hosts": "Host'lar",
     "docs": "Belgeler",
     "openMenu": "Menüyü aç",
@@ -2256,6 +2257,27 @@
         "badTag": "Geçersiz etiket",
         "badVlessRoute": "0 ile 65535 arasında tek bir sayı girin"
       }
+    },
+    "sponsors": {
+      "title": "Sponsorlar",
+      "intro": "3X-UI geliştirmesini destekleyen şirketler. Sponsor bağlantıları yeni sekmede açılır.",
+      "become": "Sponsor ol",
+      "tag": "Sponsor",
+      "visit": "Ziyaret et",
+      "yourBrand": "Markanız burada",
+      "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": {

+ 22 - 0
internal/web/translation/uk-UA.json

@@ -120,6 +120,7 @@
     "outbounds": "Вихідні",
     "apiDocs": "Документація API",
     "donate": "Підтримати",
+    "sponsors": "Спонсори",
     "hosts": "Хости",
     "docs": "Документація",
     "openMenu": "Відкрити меню",
@@ -2256,6 +2257,27 @@
         "badTag": "Недійсний тег",
         "badVlessRoute": "Введіть одне число від 0 до 65535"
       }
+    },
+    "sponsors": {
+      "title": "Спонсори",
+      "intro": "Компанії, що підтримують розробку 3X-UI. Спонсорські посилання відкриваються в новій вкладці.",
+      "become": "Стати спонсором",
+      "tag": "Спонсор",
+      "visit": "Перейти",
+      "yourBrand": "Тут може бути ваш бренд",
+      "yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI.",
+      "placements": "Доступні місця",
+      "placementDashboard": "Банер на головній",
+      "placementDashboardDesc": "Преміум-місце вгорі панелі, один спонсор за раз.",
+      "placementSidebar": "Картка в бічній панелі",
+      "placementSidebarDesc": "Показується на всіх сторінках панелі, спонсори чергуються.",
+      "placementLogin": "Сторінка входу",
+      "placementLoginDesc": "Адміністратор бачить її під час кожного входу.",
+      "placementPage": "Сторінка спонсорів",
+      "placementPageDesc": "Розміщення тут із логотипом, описом і посиланням.",
+      "available": "Вільно",
+      "takenUntil": "Зайнято до {date}",
+      "activeCount": "Активних: {count}"
     }
   },
   "tgbot": {

+ 22 - 0
internal/web/translation/vi-VN.json

@@ -120,6 +120,7 @@
     "outbounds": "Outbound",
     "apiDocs": "Tài liệu API",
     "donate": "Quyên góp",
+    "sponsors": "Nhà tài trợ",
     "hosts": "Hosts",
     "docs": "Tài liệu",
     "openMenu": "Mở menu",
@@ -2256,6 +2257,27 @@
         "badTag": "Tag không hợp lệ",
         "badVlessRoute": "Nhập một số duy nhất từ 0 đến 65535"
       }
+    },
+    "sponsors": {
+      "title": "Nhà tài trợ",
+      "intro": "Các công ty hỗ trợ phát triển 3X-UI. Liên kết tài trợ mở trong tab mới.",
+      "become": "Trở thành nhà tài trợ",
+      "tag": "Tài trợ",
+      "visit": "Truy cập",
+      "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.",
+      "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": {

+ 22 - 0
internal/web/translation/zh-CN.json

@@ -120,6 +120,7 @@
     "outbounds": "出站",
     "apiDocs": "API 文档",
     "donate": "捐赠",
+    "sponsors": "赞助商",
     "hosts": "主机",
     "docs": "文档",
     "openMenu": "打开菜单",
@@ -2256,6 +2257,27 @@
         "badTag": "无效的标签",
         "badVlessRoute": "请输入 0 到 65535 之间的单个数字"
       }
+    },
+    "sponsors": {
+      "title": "赞助商",
+      "intro": "支持 3X-UI 开发的公司。赞助链接将在新标签页中打开。",
+      "become": "成为赞助商",
+      "tag": "赞助",
+      "visit": "访问",
+      "yourBrand": "您的品牌展示位",
+      "yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。",
+      "placements": "可用展示位",
+      "placementDashboard": "概览横幅",
+      "placementDashboardDesc": "仪表盘顶部的高级位置,每次仅一家赞助商。",
+      "placementSidebar": "侧边栏卡片",
+      "placementSidebarDesc": "在面板所有页面显示,并在赞助商之间轮换。",
+      "placementLogin": "登录页",
+      "placementLoginDesc": "管理员每次登录时都能看到。",
+      "placementPage": "赞助商页面",
+      "placementPageDesc": "在此页面展示徽标、简介和链接。",
+      "available": "可预订",
+      "takenUntil": "已预订至 {date}",
+      "activeCount": "{count} 个进行中"
     }
   },
   "tgbot": {

+ 22 - 0
internal/web/translation/zh-TW.json

@@ -120,6 +120,7 @@
     "outbounds": "出站",
     "apiDocs": "API 文件",
     "donate": "捐贈",
+    "sponsors": "贊助商",
     "hosts": "主機",
     "docs": "文件",
     "openMenu": "開啟選單",
@@ -2256,6 +2257,27 @@
         "badTag": "無效的標籤",
         "badVlessRoute": "請輸入 0 到 65535 之間的單一數字"
       }
+    },
+    "sponsors": {
+      "title": "贊助商",
+      "intro": "支持 3X-UI 開發的公司。贊助連結將在新分頁中開啟。",
+      "become": "成為贊助商",
+      "tag": "贊助",
+      "visit": "造訪",
+      "yourBrand": "您的品牌展示位",
+      "yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。",
+      "placements": "可用展示位",
+      "placementDashboard": "總覽橫幅",
+      "placementDashboardDesc": "儀表板頂部的高級位置,每次僅一家贊助商。",
+      "placementSidebar": "側邊欄卡片",
+      "placementSidebarDesc": "在面板所有頁面顯示,並在贊助商之間輪換。",
+      "placementLogin": "登入頁",
+      "placementLoginDesc": "管理員每次登入時都能看到。",
+      "placementPage": "贊助商頁面",
+      "placementPageDesc": "在此頁面展示標誌、簡介與連結。",
+      "available": "可預訂",
+      "takenUntil": "已預訂至 {date}",
+      "activeCount": "{count} 個進行中"
     }
   },
   "tgbot": {

+ 1 - 1
tools/openapigen/main.go

@@ -112,7 +112,7 @@ func run(root, outDir string) error {
 		},
 		{
 			Path:        resolveRel(root, "internal/web/service/panel"),
-			StructAllow: setOf("ApiTokenView", "PanelUpdateStatus"),
+			StructAllow: setOf("ApiTokenView", "PanelUpdateStatus", "Sponsor", "SponsorList"),
 		},
 		{
 			Path:        resolveRel(root, "internal/amneziawg"),