Browse Source

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 10 hours ago
parent
commit
fd7b3559bc
47 changed files with 2323 additions and 10 deletions
  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
         this — the middleware short-circuits CSRF for authenticated API
         requests.
         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'
       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
     - depth: 2
       title: Returns whether 2FA is enabled on the panel — used by the login page to
       title: Returns whether 2FA is enabled on the panel — used by the login page to
         decide whether to show the OTP field.
         decide whether to show the OTP field.
@@ -39,6 +45,11 @@ _openapi:
           this — the middleware short-circuits CSRF for authenticated API
           this — the middleware short-circuits CSRF for authenticated API
           requests.
           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
         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
       - content: Returns whether 2FA is enabled on the panel — used by the login page to
           decide whether to show the OTP field.
           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
         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 (
   return (
     <>
     <>
       {props.children}
       {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"
         "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": {
       "SubBalancer": {
         "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
         "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
         "properties": {
         "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": {
     "/getTwoFactorEnable": {
       "post": {
       "post": {
         "tags": [
         "tags": [

+ 131 - 0
frontend/public/openapi.json

@@ -4135,6 +4135,84 @@
         ],
         ],
         "type": "object"
         "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": {
       "SubBalancer": {
         "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
         "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
         "properties": {
         "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": {
     "/getTwoFactorEnable": {
       "post": {
       "post": {
         "tags": [
         "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 };
   return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
 }
 }
 
 
+export function withBasePath(path: string): string {
+  return basePathPrefix + path;
+}
+
 export function setupHttp(): void {
 export function setupHttp(): void {
   let basePath: string | null | undefined = window.X_UI_BASE_PATH;
   let basePath: string | null | undefined = window.X_UI_BASE_PATH;
   if (!basePath) {
   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 = {
 export const keys = {
+  sponsors: () => ['sponsors'] as const,
   server: {
   server: {
     status: () => ['server', 'status'] as const,
     status: () => ['server', 'status'] as const,
     fail2banStatus: () => ['server', 'fail2banStatus'] as const,
     fail2banStatus: () => ['server', 'fail2banStatus'] as const,

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

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

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

@@ -4109,6 +4109,84 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     ],
     "type": "object"
     "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": {
   "SubBalancer": {
     "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
     "description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
     "properties": {
     "properties": {

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

@@ -939,6 +939,23 @@ export interface Setting {
   value: string;
   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 {
 export interface SubBalancer {
   createdAt: number;
   createdAt: number;
   enabled: boolean;
   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 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({
 export const SubBalancerSchema = z.object({
   createdAt: z.number().int(),
   createdAt: z.number().int(),
   enabled: z.boolean(),
   enabled: z.boolean(),

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

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

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

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

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

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

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

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

@@ -411,3 +411,7 @@
 .submit-row {
 .submit-row {
   margin-bottom: 0;
   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 { HttpUtil, LanguageManager } from '@/utils';
 import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { setMessageInstance } from '@/utils/messageBus';
 import { setMessageInstance } from '@/utils/messageBus';
+import SponsorSlot from '@/components/sponsor/SponsorSlot';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
 import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
 import './LoginPage.css';
 import './LoginPage.css';
@@ -247,6 +248,7 @@ export default function LoginPage() {
                     </Form.Item>
                     </Form.Item>
                   </Form>
                   </Form>
                 </FormProvider>
                 </FormProvider>
+                <SponsorSlot slot="login" variant="compact" className="login-sponsor" />
               </div>
               </div>
             )}
             )}
           </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 SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
 const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
 const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
 const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
 const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
+const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
 
 
 function withSuspense(node: React.ReactNode) {
 function withSuspense(node: React.ReactNode) {
   return (
   return (
@@ -51,6 +52,7 @@ const routes: RouteObject[] = [
       { path: 'outbound', element: withSuspense(<XrayPage />) },
       { path: 'outbound', element: withSuspense(<XrayPage />) },
       { path: 'routing', element: withSuspense(<XrayPage />) },
       { path: 'routing', element: withSuspense(<XrayPage />) },
       { path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
       { 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,
 .settings-page,
 .nodes-page,
 .nodes-page,
 .groups-page,
 .groups-page,
-.api-docs-page {
+.api-docs-page,
+.sponsors-page {
   --bg-page: #e6e8ec;
   --bg-page: #e6e8ec;
   --bg-card: #ffffff;
   --bg-card: #ffffff;
   min-height: 100vh;
   min-height: 100vh;
@@ -19,7 +20,8 @@
 .settings-page.is-dark,
 .settings-page.is-dark,
 .nodes-page.is-dark,
 .nodes-page.is-dark,
 .groups-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-page: #1a1b1f;
   --bg-card: #23252b;
   --bg-card: #23252b;
 }
 }
@@ -31,7 +33,8 @@
 .settings-page.is-dark.is-ultra,
 .settings-page.is-dark.is-ultra,
 .nodes-page.is-dark.is-ultra,
 .nodes-page.is-dark.is-ultra,
 .groups-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-page: #000;
   --bg-card: #101013;
   --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,
     port: 5173,
     strictPort: true,
     strictPort: true,
     proxy: {
     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),
       '^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
       '^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
       '^/(?:[^/]+/)?ws$': {
       '^/(?:[^/]+/)?ws$': {

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

@@ -6,6 +6,7 @@ import (
 	"time"
 	"time"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"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/middleware"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
@@ -28,6 +29,7 @@ type IndexController struct {
 
 
 	settingService service.SettingService
 	settingService service.SettingService
 	userService    panel.UserService
 	userService    panel.UserService
+	panelService   panel.PanelService
 	tgbot          tgbot.Tgbot
 	tgbot          tgbot.Tgbot
 }
 }
 
 
@@ -42,12 +44,36 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
 func (a *IndexController) initRouter(g *gin.RouterGroup) {
 func (a *IndexController) initRouter(g *gin.RouterGroup) {
 	g.GET("/", a.index)
 	g.GET("/", a.index)
 	g.GET("/csrf-token", a.csrfToken)
 	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("/login", middleware.CSRFMiddleware(), a.login)
 	g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
 	g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
 	g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
 	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.
 // index handles the root route, redirecting logged-in users to the panel or showing the login page.
 func (a *IndexController) index(c *gin.Context) {
 func (a *IndexController) index(c *gin.Context) {
 	if session.IsLogin(c) {
 	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.
 constructs the real router and diffs it against the registry both ways.
 
 
 Scope: everything under /panel/api/ plus the session-auth surface the
 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{
 var contractExtraRoutes = map[string]bool{
 	"POST /login":              true,
 	"POST /login":              true,
 	"POST /logout":             true,
 	"POST /logout":             true,
 	"GET /csrf-token":          true,
 	"GET /csrf-token":          true,
+	"GET /sponsors":            true,
 	"POST /getTwoFactorEnable": true,
 	"POST /getTwoFactorEnable": true,
 	"GET /ws":                  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": "الصادرات",
     "outbounds": "الصادرات",
     "apiDocs": "توثيق API",
     "apiDocs": "توثيق API",
     "donate": "تبرع",
     "donate": "تبرع",
+    "sponsors": "الرعاة",
     "hosts": "المضيفات",
     "hosts": "المضيفات",
     "docs": "التوثيق",
     "docs": "التوثيق",
     "openMenu": "فتح القائمة",
     "openMenu": "فتح القائمة",
@@ -2256,6 +2257,27 @@
         "badTag": "وسم غير صالح",
         "badTag": "وسم غير صالح",
         "badVlessRoute": "أدخل رقماً واحداً بين 0 و65535"
         "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": {
   "tgbot": {

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

@@ -121,6 +121,7 @@
     "outbounds": "Outbounds",
     "outbounds": "Outbounds",
     "apiDocs": "API Docs",
     "apiDocs": "API Docs",
     "donate": "Donate",
     "donate": "Donate",
+    "sponsors": "Sponsors",
     "docs": "Documentation",
     "docs": "Documentation",
     "openMenu": "Open menu",
     "openMenu": "Open menu",
     "pinSidebar": "Pin sidebar",
     "pinSidebar": "Pin sidebar",
@@ -2256,6 +2257,27 @@
       },
       },
       "defaultOutbound": "Default Outbound",
       "defaultOutbound": "Default Outbound",
       "defaultOutboundDesc": "Traffic that does not match any routing rule uses this outbound (Xray uses the first outbound in the list)."
       "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "Salidas",
     "outbounds": "Salidas",
     "apiDocs": "Documentación de la API",
     "apiDocs": "Documentación de la API",
     "donate": "Donar",
     "donate": "Donar",
+    "sponsors": "Patrocinadores",
     "hosts": "Hosts",
     "hosts": "Hosts",
     "docs": "Documentación",
     "docs": "Documentación",
     "openMenu": "Abrir menú",
     "openMenu": "Abrir menú",
@@ -2256,6 +2257,27 @@
         "badTag": "Etiqueta no válida",
         "badTag": "Etiqueta no válida",
         "badVlessRoute": "Introduce un único número entre 0 y 65535"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "خروجی‌ها",
     "outbounds": "خروجی‌ها",
     "apiDocs": "مستندات API",
     "apiDocs": "مستندات API",
     "donate": "حمایت مالی",
     "donate": "حمایت مالی",
+    "sponsors": "اسپانسرها",
     "hosts": "میزبان‌ها",
     "hosts": "میزبان‌ها",
     "docs": "مستندات",
     "docs": "مستندات",
     "openMenu": "باز کردن منو",
     "openMenu": "باز کردن منو",
@@ -2256,6 +2257,27 @@
         "badTag": "برچسب نامعتبر",
         "badTag": "برچسب نامعتبر",
         "badVlessRoute": "یک عدد بین 0 تا 65535 وارد کنید"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "Outbound",
     "outbounds": "Outbound",
     "apiDocs": "Dokumentasi API",
     "apiDocs": "Dokumentasi API",
     "donate": "Donasi",
     "donate": "Donasi",
+    "sponsors": "Sponsor",
     "hosts": "Host",
     "hosts": "Host",
     "docs": "Dokumentasi",
     "docs": "Dokumentasi",
     "openMenu": "Buka menu",
     "openMenu": "Buka menu",
@@ -2256,6 +2257,27 @@
         "badTag": "Tag tidak valid",
         "badTag": "Tag tidak valid",
         "badVlessRoute": "Masukkan satu angka antara 0 dan 65535"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "アウトバウンド",
     "outbounds": "アウトバウンド",
     "apiDocs": "API ドキュメント",
     "apiDocs": "API ドキュメント",
     "donate": "寄付",
     "donate": "寄付",
+    "sponsors": "スポンサー",
     "hosts": "ホスト",
     "hosts": "ホスト",
     "docs": "ドキュメント",
     "docs": "ドキュメント",
     "openMenu": "メニューを開く",
     "openMenu": "メニューを開く",
@@ -2256,6 +2257,27 @@
         "badTag": "無効なタグ",
         "badTag": "無効なタグ",
         "badVlessRoute": "0〜65535 の単一の数値を入力してください"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "Saídas",
     "outbounds": "Saídas",
     "apiDocs": "Documentação da API",
     "apiDocs": "Documentação da API",
     "donate": "Doar",
     "donate": "Doar",
+    "sponsors": "Patrocinadores",
     "hosts": "Hosts",
     "hosts": "Hosts",
     "docs": "Documentação",
     "docs": "Documentação",
     "openMenu": "Abrir menu",
     "openMenu": "Abrir menu",
@@ -2256,6 +2257,27 @@
         "badTag": "Tag inválida",
         "badTag": "Tag inválida",
         "badVlessRoute": "Insira um único número entre 0 e 65535"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "Исходящие",
     "outbounds": "Исходящие",
     "apiDocs": "Документация API",
     "apiDocs": "Документация API",
     "donate": "Поддержать",
     "donate": "Поддержать",
+    "sponsors": "Спонсоры",
     "hosts": "Хосты",
     "hosts": "Хосты",
     "docs": "Документация",
     "docs": "Документация",
     "openMenu": "Открыть меню",
     "openMenu": "Открыть меню",
@@ -2256,6 +2257,27 @@
         "badTag": "Недопустимый тег",
         "badTag": "Недопустимый тег",
         "badVlessRoute": "Введите одно число от 0 до 65535"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "Giden Bağlantılar",
     "outbounds": "Giden Bağlantılar",
     "apiDocs": "API Belgeleri",
     "apiDocs": "API Belgeleri",
     "donate": "Bağış Yap",
     "donate": "Bağış Yap",
+    "sponsors": "Sponsorlar",
     "hosts": "Host'lar",
     "hosts": "Host'lar",
     "docs": "Belgeler",
     "docs": "Belgeler",
     "openMenu": "Menüyü aç",
     "openMenu": "Menüyü aç",
@@ -2256,6 +2257,27 @@
         "badTag": "Geçersiz etiket",
         "badTag": "Geçersiz etiket",
         "badVlessRoute": "0 ile 65535 arasında tek bir sayı girin"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "Вихідні",
     "outbounds": "Вихідні",
     "apiDocs": "Документація API",
     "apiDocs": "Документація API",
     "donate": "Підтримати",
     "donate": "Підтримати",
+    "sponsors": "Спонсори",
     "hosts": "Хости",
     "hosts": "Хости",
     "docs": "Документація",
     "docs": "Документація",
     "openMenu": "Відкрити меню",
     "openMenu": "Відкрити меню",
@@ -2256,6 +2257,27 @@
         "badTag": "Недійсний тег",
         "badTag": "Недійсний тег",
         "badVlessRoute": "Введіть одне число від 0 до 65535"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "Outbound",
     "outbounds": "Outbound",
     "apiDocs": "Tài liệu API",
     "apiDocs": "Tài liệu API",
     "donate": "Quyên góp",
     "donate": "Quyên góp",
+    "sponsors": "Nhà tài trợ",
     "hosts": "Hosts",
     "hosts": "Hosts",
     "docs": "Tài liệu",
     "docs": "Tài liệu",
     "openMenu": "Mở menu",
     "openMenu": "Mở menu",
@@ -2256,6 +2257,27 @@
         "badTag": "Tag không hợp lệ",
         "badTag": "Tag không hợp lệ",
         "badVlessRoute": "Nhập một số duy nhất từ 0 đến 65535"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "出站",
     "outbounds": "出站",
     "apiDocs": "API 文档",
     "apiDocs": "API 文档",
     "donate": "捐赠",
     "donate": "捐赠",
+    "sponsors": "赞助商",
     "hosts": "主机",
     "hosts": "主机",
     "docs": "文档",
     "docs": "文档",
     "openMenu": "打开菜单",
     "openMenu": "打开菜单",
@@ -2256,6 +2257,27 @@
         "badTag": "无效的标签",
         "badTag": "无效的标签",
         "badVlessRoute": "请输入 0 到 65535 之间的单个数字"
         "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": {
   "tgbot": {

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

@@ -120,6 +120,7 @@
     "outbounds": "出站",
     "outbounds": "出站",
     "apiDocs": "API 文件",
     "apiDocs": "API 文件",
     "donate": "捐贈",
     "donate": "捐贈",
+    "sponsors": "贊助商",
     "hosts": "主機",
     "hosts": "主機",
     "docs": "文件",
     "docs": "文件",
     "openMenu": "開啟選單",
     "openMenu": "開啟選單",
@@ -2256,6 +2257,27 @@
         "badTag": "無效的標籤",
         "badTag": "無效的標籤",
         "badVlessRoute": "請輸入 0 到 65535 之間的單一數字"
         "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": {
   "tgbot": {

+ 1 - 1
tools/openapigen/main.go

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