3 Commits 61885c9126 ... f8b72de995

Author SHA1 Message Date
  MHSanaei f8b72de995 fix(node-sync): write only changed client rows in the node traffic merge 11 hours ago
  MHSanaei 6f1dd0d757 feat(sponsors): drop the sidebar slot and simplify the Sponsors page 12 hours ago
  MHSanaei 319e693276 fix(sponsor): keep long sponsor copy inside every slot 13 hours ago
30 changed files with 549 additions and 626 deletions
  1. 51 33
      frontend/src/components/sponsor/SponsorCard.css
  2. 72 6
      frontend/src/components/sponsor/SponsorCard.stories.tsx
  3. 2 22
      frontend/src/components/sponsor/SponsorCard.tsx
  4. 3 21
      frontend/src/components/sponsor/SponsorSlot.tsx
  5. 0 4
      frontend/src/layouts/AppSidebar.css
  6. 0 9
      frontend/src/layouts/AppSidebar.tsx
  7. 1 23
      frontend/src/lib/sponsors.ts
  8. 2 70
      frontend/src/pages/sponsors/SponsorsPage.css
  9. 7 90
      frontend/src/pages/sponsors/SponsorsPage.tsx
  10. 5 56
      frontend/src/test/sponsors.test.tsx
  11. 32 0
      internal/web/service/client_flow_isolation_test.go
  12. 114 16
      internal/web/service/client_link.go
  13. 75 62
      internal/web/service/inbound_node.go
  14. 162 0
      internal/web/service/node_client_traffic_sum_test.go
  15. 3 10
      internal/web/service/panel/sponsor.go
  16. 2 31
      internal/web/service/panel/sponsor_test.go
  17. 5 4
      internal/web/service/xray_wireguard_config_test.go
  18. 1 13
      internal/web/translation/ar-EG.json
  19. 1 13
      internal/web/translation/en-US.json
  20. 1 13
      internal/web/translation/es-ES.json
  21. 1 13
      internal/web/translation/fa-IR.json
  22. 1 13
      internal/web/translation/id-ID.json
  23. 1 13
      internal/web/translation/ja-JP.json
  24. 1 13
      internal/web/translation/pt-BR.json
  25. 1 13
      internal/web/translation/ru-RU.json
  26. 1 13
      internal/web/translation/tr-TR.json
  27. 1 13
      internal/web/translation/uk-UA.json
  28. 1 13
      internal/web/translation/vi-VN.json
  29. 1 13
      internal/web/translation/zh-CN.json
  30. 1 13
      internal/web/translation/zh-TW.json

+ 51 - 33
frontend/src/components/sponsor/SponsorCard.css

@@ -61,7 +61,7 @@
 
 
 .sponsor-head {
 .sponsor-head {
   display: flex;
   display: flex;
-  align-items: center;
+  align-items: baseline;
   gap: 8px;
   gap: 8px;
   min-width: 0;
   min-width: 0;
 }
 }
@@ -77,15 +77,14 @@
 }
 }
 
 
 .sponsor-title {
 .sponsor-title {
-  overflow: hidden;
   font-weight: 600;
   font-weight: 600;
-  white-space: nowrap;
-  text-overflow: ellipsis;
+  overflow-wrap: anywhere;
 }
 }
 
 
 .sponsor-text {
 .sponsor-text {
   font-size: 13px;
   font-size: 13px;
   color: var(--ant-color-text-secondary);
   color: var(--ant-color-text-secondary);
+  overflow-wrap: anywhere;
 }
 }
 
 
 .sponsor-visit {
 .sponsor-visit {
@@ -129,42 +128,52 @@
 }
 }
 
 
 .sponsor-card-compact .sponsor-main {
 .sponsor-card-compact .sponsor-main {
-  gap: 10px;
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  align-items: center;
+  gap: 4px 8px;
   padding: 8px 10px;
   padding: 8px 10px;
 }
 }
 
 
+.sponsor-card-compact .sponsor-body,
+.sponsor-card-compact .sponsor-head {
+  display: contents;
+}
+
 .sponsor-card-compact .sponsor-logo {
 .sponsor-card-compact .sponsor-logo {
-  width: 32px;
-  height: 32px;
+  width: 28px;
+  height: 28px;
 }
 }
 
 
-.sponsor-card-compact .sponsor-head {
-  flex-direction: column;
-  align-items: flex-start;
-  gap: 2px;
+.sponsor-card-compact .sponsor-tag {
+  justify-self: start;
 }
 }
 
 
-.sponsor-card-compact .sponsor-title {
+.sponsor-card-compact .sponsor-title,
+.sponsor-card-compact .sponsor-text {
   display: -webkit-box;
   display: -webkit-box;
-  max-width: 100%;
+  grid-column: 1 / -1;
+  overflow: hidden;
+  -webkit-box-orient: vertical;
+}
+
+.sponsor-card-compact .sponsor-title {
   font-size: 13px;
   font-size: 13px;
-  white-space: normal;
   -webkit-line-clamp: 2;
   -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
 }
 }
 
 
 .sponsor-card-compact .sponsor-text {
 .sponsor-card-compact .sponsor-text {
-  display: -webkit-box;
-  overflow: hidden;
   font-size: 12px;
   font-size: 12px;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 4;
 }
 }
 
 
 .sponsor-card-compact .sponsor-close {
 .sponsor-card-compact .sponsor-close {
+  position: absolute;
+  top: 4px;
+  inset-inline-end: 4px;
   width: 22px;
   width: 22px;
   height: 22px;
   height: 22px;
-  margin: 4px 4px 0 0;
+  margin: 0;
   font-size: 11px;
   font-size: 11px;
 }
 }
 
 
@@ -175,28 +184,37 @@
 .sponsor-card-card .sponsor-main {
 .sponsor-card-card .sponsor-main {
   flex-direction: column;
   flex-direction: column;
   align-items: flex-start;
   align-items: flex-start;
-  padding: 16px;
+  gap: 16px;
+  padding: 24px;
 }
 }
 
 
 .sponsor-card-card .sponsor-logo {
 .sponsor-card-card .sponsor-logo {
-  width: 56px;
-  height: 56px;
+  width: 72px;
+  height: 72px;
+  border-radius: 12px;
 }
 }
 
 
-.sponsor-card-card .sponsor-visit {
-  margin-top: auto;
+.sponsor-card-card .sponsor-logo-fallback {
+  font-size: 28px;
 }
 }
 
 
-.sponsor-card-icon {
-  justify-content: center;
-  padding: 6px;
-  border-color: transparent;
-  background: transparent;
+.sponsor-card-card .sponsor-body {
+  gap: 6px;
+}
+
+.sponsor-card-card .sponsor-title {
+  font-size: 17px;
+  line-height: 1.4;
 }
 }
 
 
-.sponsor-card-icon .sponsor-logo {
-  width: 32px;
-  height: 32px;
+.sponsor-card-card .sponsor-text {
+  font-size: 14px;
+  line-height: 1.6;
+}
+
+.sponsor-card-card .sponsor-visit {
+  margin-top: auto;
+  font-size: 14px;
 }
 }
 
 
 @media (max-width: 576px) {
 @media (max-width: 576px) {

+ 72 - 6
frontend/src/components/sponsor/SponsorCard.stories.tsx

@@ -7,13 +7,41 @@ import SponsorCard from './SponsorCard';
 const sponsor: Sponsor = {
 const sponsor: Sponsor = {
   id: 'acme-2026-10',
   id: 'acme-2026-10',
   name: 'Acme VPS',
   name: 'Acme VPS',
-  slots: ['dashboard', 'sidebar', 'page', 'login'],
+  slots: ['dashboard', 'page', 'login'],
   until: '2099-01-01T00:00:00Z',
   until: '2099-01-01T00:00:00Z',
   title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
   title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
   text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
   text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
   link: 'https://acme.example/?utm_source=3x-ui',
   link: 'https://acme.example/?utm_source=3x-ui',
 };
 };
 
 
+// Copy as long as the real sponsors.json entries; each story renders it at its slot's width.
+const longSponsor: Sponsor = {
+  ...sponsor,
+  title: { en: 'Advertise here — reach 3X-UI server admins' },
+  text: {
+    en: 'Put your VPS, hosting or domain brand in front of the admins who use 3X-UI every day. Message us for slots and pricing.',
+  },
+};
+
+function part(root: HTMLElement, selector: string): HTMLElement {
+  const el = root.querySelector<HTMLElement>(selector);
+  if (!el) throw new Error(`missing ${selector}`);
+  return el;
+}
+
+async function expectInsideCard(root: HTMLElement, selector: string) {
+  const card = part(root, '.sponsor-card').getBoundingClientRect();
+  const box = part(root, selector).getBoundingClientRect();
+  await expect(box.left).toBeGreaterThanOrEqual(card.left - 0.5);
+  await expect(box.right).toBeLessThanOrEqual(card.right + 0.5);
+}
+
+async function expectNotClipped(root: HTMLElement, selector: string) {
+  const el = part(root, selector);
+  await expect(el.scrollWidth).toBeLessThanOrEqual(el.clientWidth + 1);
+  await expect(el.scrollHeight).toBeLessThanOrEqual(el.clientHeight + 1);
+}
+
 const meta = {
 const meta = {
   title: 'Sponsor/SponsorCard',
   title: 'Sponsor/SponsorCard',
   component: SponsorCard,
   component: SponsorCard,
@@ -29,9 +57,8 @@ const meta = {
   argTypes: {
   argTypes: {
     sponsor: { description: 'Sponsor entry from GET /sponsors.' },
     sponsor: { description: 'Sponsor entry from GET /sponsors.' },
     variant: {
     variant: {
-      description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
+      description: 'banner (dashboard), compact (login) or card (Sponsors page).',
     },
     },
-    iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
     onClose: { description: 'When set, shows a close button (temporary dismiss).' },
     onClose: { description: 'When set, shows a close button (temporary dismiss).' },
   },
   },
   args: { sponsor },
   args: { sponsor },
@@ -53,7 +80,7 @@ export const Banner: Story = {
 export const Compact: Story = {
 export const Compact: Story = {
   args: { variant: 'compact', onClose: () => {} },
   args: { variant: 'compact', onClose: () => {} },
   render: (args) => (
   render: (args) => (
-    <div style={{ width: 204 }}>
+    <div style={{ width: 334 }}>
       <SponsorCard {...args} />
       <SponsorCard {...args} />
     </div>
     </div>
   ),
   ),
@@ -68,6 +95,45 @@ export const Card: Story = {
   ),
   ),
 };
 };
 
 
-export const IconOnly: Story = {
-  args: { iconOnly: true },
+export const BannerLongCopy: Story = {
+  args: { variant: 'banner', sponsor: longSponsor, onClose: () => {} },
+  render: (args) => (
+    <div style={{ width: 360 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+  play: async ({ canvasElement }) => {
+    await expectNotClipped(canvasElement, '.sponsor-title');
+  },
+};
+
+export const LoginLongCopy: Story = {
+  args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
+  render: (args) => (
+    <div style={{ width: 334 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+  play: async ({ canvasElement }) => {
+    await expectNotClipped(canvasElement, '.sponsor-title');
+    await expectNotClipped(canvasElement, '.sponsor-text');
+    await expect(part(canvasElement, '.sponsor-main')).toHaveAttribute(
+      'title',
+      `${longSponsor.title.en}\n${longSponsor.text.en}`,
+    );
+  },
+};
+
+export const PageLongCopy: Story = {
+  args: { variant: 'card', sponsor: longSponsor },
+  render: (args) => (
+    <div style={{ width: 320 }}>
+      <SponsorCard {...args} />
+    </div>
+  ),
+  play: async ({ canvasElement }) => {
+    await expectInsideCard(canvasElement, '.sponsor-title');
+    await expectInsideCard(canvasElement, '.sponsor-text');
+    await expectNotClipped(canvasElement, '.sponsor-title');
+  },
 };
 };

+ 2 - 22
frontend/src/components/sponsor/SponsorCard.tsx

@@ -12,7 +12,6 @@ export type SponsorCardVariant = 'banner' | 'compact' | 'card';
 export interface SponsorCardProps {
 export interface SponsorCardProps {
   sponsor: Sponsor;
   sponsor: Sponsor;
   variant?: SponsorCardVariant;
   variant?: SponsorCardVariant;
-  iconOnly?: boolean;
   onClose?: () => void;
   onClose?: () => void;
 }
 }
 
 
@@ -36,33 +35,13 @@ function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
   );
   );
 }
 }
 
 
-export default function SponsorCard({
-  sponsor,
-  variant = 'banner',
-  iconOnly = false,
-  onClose,
-}: SponsorCardProps) {
+export default function SponsorCard({ sponsor, variant = 'banner', onClose }: SponsorCardProps) {
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
   const lang = i18n.resolvedLanguage || i18n.language || 'en';
   const lang = i18n.resolvedLanguage || i18n.language || 'en';
   const title = pickLocale(sponsor.title, lang) || sponsor.name;
   const title = pickLocale(sponsor.title, lang) || sponsor.name;
   const text = pickLocale(sponsor.text, lang);
   const text = pickLocale(sponsor.text, lang);
   const tag = t('pages.sponsors.tag');
   const tag = t('pages.sponsors.tag');
 
 
-  if (iconOnly) {
-    return (
-      <a
-        className="sponsor-card sponsor-card-icon"
-        href={sponsor.link}
-        target="_blank"
-        rel="noopener noreferrer sponsored"
-        title={`${tag} · ${title}`}
-        aria-label={`${tag}: ${title}`}
-      >
-        <SponsorLogo sponsor={sponsor} />
-      </a>
-    );
-  }
-
   return (
   return (
     <div className={`sponsor-card sponsor-card-${variant}`}>
     <div className={`sponsor-card sponsor-card-${variant}`}>
       <a
       <a
@@ -70,6 +49,7 @@ export default function SponsorCard({
         href={sponsor.link}
         href={sponsor.link}
         target="_blank"
         target="_blank"
         rel="noopener noreferrer sponsored"
         rel="noopener noreferrer sponsored"
+        title={variant === 'compact' ? [title, text].filter(Boolean).join('\n') : undefined}
       >
       >
         <SponsorLogo sponsor={sponsor} />
         <SponsorLogo sponsor={sponsor} />
         <span className="sponsor-body" dir="auto">
         <span className="sponsor-body" dir="auto">

+ 3 - 21
frontend/src/components/sponsor/SponsorSlot.tsx

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

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

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

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

@@ -44,7 +44,6 @@ import { formatPanelVersion } from '@/lib/panel-version';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { useAllSettings } from '@/api/queries/useAllSettings';
 import { useAllSettings } from '@/api/queries/useAllSettings';
 import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
 import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
-import SponsorSlot from '@/components/sponsor/SponsorSlot';
 import './AppSidebar.css';
 import './AppSidebar.css';
 
 
 const DONATE_URL = 'https://donate.sanaei.dev/';
 const DONATE_URL = 'https://donate.sanaei.dev/';
@@ -452,13 +451,6 @@ export default function AppSidebar() {
           onClick={onMenuClick}
           onClick={onMenuClick}
         />
         />
         <div className="sider-footer">
         <div className="sider-footer">
-          <SponsorSlot
-            slot="sidebar"
-            variant="compact"
-            iconOnly={railCollapsed}
-            rotate
-            className="sider-sponsor"
-          />
           <VersionBadge version={panelVersion} collapsed={railCollapsed} />
           <VersionBadge version={panelVersion} collapsed={railCollapsed} />
         </div>
         </div>
       </Layout.Sider>
       </Layout.Sider>
@@ -544,7 +536,6 @@ export default function AppSidebar() {
           }}
           }}
         />
         />
         <div className="drawer-footer">
         <div className="drawer-footer">
-          <SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
           <VersionBadge version={panelVersion} />
           <VersionBadge version={panelVersion} />
         </div>
         </div>
       </Drawer>
       </Drawer>

+ 1 - 23
frontend/src/lib/sponsors.ts

@@ -1,6 +1,6 @@
 import type { Sponsor } from '@/generated/types';
 import type { Sponsor } from '@/generated/types';
 
 
-export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
+export type SponsorSlot = 'dashboard' | 'page' | 'login';
 
 
 const DISMISS_KEY = 'xui.sponsor.dismissed';
 const DISMISS_KEY = 'xui.sponsor.dismissed';
 export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
 export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
@@ -38,25 +38,3 @@ export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now())
     localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
     localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
   } catch {}
   } catch {}
 }
 }
-
-// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
-export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
-  dashboard: 1,
-  login: 1,
-  sidebar: 3,
-};
-
-export interface PlacementStatus {
-  count: number;
-  capacity?: number;
-  takenUntil?: string;
-}
-
-// When full, a place frees up once enough bookings end to drop below capacity.
-export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
-  const booked = sponsorsForSlot(sponsors, slot);
-  const capacity = SLOT_CAPACITY[slot];
-  if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
-  const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
-  return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
-}

+ 2 - 70
frontend/src/pages/sponsors/SponsorsPage.css

@@ -29,30 +29,14 @@
   color: #faad14;
   color: #faad14;
 }
 }
 
 
-.sponsors-page .sponsors-section-title {
-  margin: 32px 0 12px;
-  color: var(--ant-color-text-secondary);
-  font-weight: 600;
-  letter-spacing: 0.3px;
-}
-
 .sponsors-yourbrand {
 .sponsors-yourbrand {
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
-  align-items: flex-start;
+  align-items: center;
   gap: 8px;
   gap: 8px;
-  height: 100%;
-  min-height: 170px;
-  padding: 16px;
+  padding: 40px 24px;
   border: 1.5px dashed var(--ant-color-border);
   border: 1.5px dashed var(--ant-color-border);
   border-radius: var(--ant-border-radius-lg, 8px);
   border-radius: var(--ant-border-radius-lg, 8px);
-  background: transparent;
-}
-
-.sponsors-yourbrand.is-large {
-  align-items: center;
-  min-height: 0;
-  padding: 40px 24px;
   text-align: center;
   text-align: center;
   background: var(--bg-card, transparent);
   background: var(--bg-card, transparent);
 }
 }
@@ -82,58 +66,6 @@
   color: var(--ant-color-text-secondary);
   color: var(--ant-color-text-secondary);
 }
 }
 
 
-.sponsors-yourbrand:not(.is-large) .ant-btn {
-  margin-top: auto;
-}
-
-.sponsors-placement {
-  display: flex;
-  gap: 12px;
-  height: 100%;
-  padding: 14px 16px;
-  border: 1px solid var(--ant-color-border-secondary);
-  border-radius: var(--ant-border-radius-lg, 8px);
-  background: var(--bg-card, transparent);
-}
-
-.sponsors-placement-icon {
-  display: inline-flex;
-  flex: 0 0 auto;
-  align-items: center;
-  justify-content: center;
-  width: 36px;
-  height: 36px;
-  border-radius: 8px;
-  font-size: 17px;
-  color: var(--ant-color-primary);
-  background: var(--ant-color-primary-bg);
-}
-
-.sponsors-placement-title {
-  font-weight: 600;
-  color: var(--ant-color-text);
-}
-
-.sponsors-placement-body {
-  min-width: 0;
-}
-
-.sponsors-placement-status {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 4px;
-  margin-top: 8px;
-}
-
-.sponsors-placement-status .ant-tag {
-  margin: 0;
-}
-
-.sponsors-placement-desc {
-  font-size: 13px;
-  color: var(--ant-color-text-secondary);
-}
-
 @media (max-width: 768px) {
 @media (max-width: 768px) {
   .sponsors-page .content-area {
   .sponsors-page .content-area {
     padding: 12px;
     padding: 12px;

+ 7 - 90
frontend/src/pages/sponsors/SponsorsPage.tsx

@@ -1,22 +1,13 @@
 import { useMemo } from 'react';
 import { useMemo } from 'react';
-import type { ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
-import {
-  CrownOutlined,
-  DashboardOutlined,
-  LoginOutlined,
-  MenuUnfoldOutlined,
-  PlusOutlined,
-} from '@ant-design/icons';
+import { Button, Col, ConfigProvider, Layout, Row, Spin, Typography } from 'antd';
+import { CrownOutlined, PlusOutlined } from '@ant-design/icons';
 
 
 import { useTheme } from '@/hooks/useTheme';
 import { useTheme } from '@/hooks/useTheme';
 import AppSidebar from '@/layouts/AppSidebar';
 import AppSidebar from '@/layouts/AppSidebar';
 import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
 import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
 import SponsorCard from '@/components/sponsor/SponsorCard';
 import SponsorCard from '@/components/sponsor/SponsorCard';
-import { IntlUtil } from '@/utils';
-import { useDatepicker } from '@/hooks/useDatepicker';
-import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
+import { sponsorsForSlot } from '@/lib/sponsors';
 import './SponsorsPage.css';
 import './SponsorsPage.css';
 
 
 function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
 function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
@@ -36,10 +27,10 @@ function BecomeButton({ contact, block }: { contact?: string; block?: boolean })
   );
   );
 }
 }
 
 
-function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
+function YourBrandCard({ contact }: { contact?: string }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
-    <div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
+    <div className="sponsors-yourbrand">
       <span className="sponsors-yourbrand-icon">
       <span className="sponsors-yourbrand-icon">
         <PlusOutlined />
         <PlusOutlined />
       </span>
       </span>
@@ -56,34 +47,6 @@ export default function SponsorsPage() {
   const { data, fetched } = useSponsorsQuery();
   const { data, fetched } = useSponsorsQuery();
   const sponsors = sponsorsForSlot(data.sponsors, 'page');
   const sponsors = sponsorsForSlot(data.sponsors, 'page');
 
 
-  const { datepicker } = useDatepicker();
-  const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
-    {
-      slot: 'dashboard',
-      icon: <DashboardOutlined />,
-      title: t('pages.sponsors.placementDashboard'),
-      desc: t('pages.sponsors.placementDashboardDesc'),
-    },
-    {
-      slot: 'sidebar',
-      icon: <MenuUnfoldOutlined />,
-      title: t('pages.sponsors.placementSidebar'),
-      desc: t('pages.sponsors.placementSidebarDesc'),
-    },
-    {
-      slot: 'login',
-      icon: <LoginOutlined />,
-      title: t('pages.sponsors.placementLogin'),
-      desc: t('pages.sponsors.placementLoginDesc'),
-    },
-    {
-      slot: 'page',
-      icon: <CrownOutlined />,
-      title: t('pages.sponsors.placementPage'),
-      desc: t('pages.sponsors.placementPageDesc'),
-    },
-  ];
-
   const pageClass = useMemo(() => {
   const pageClass = useMemo(() => {
     const classes = ['sponsors-page'];
     const classes = ['sponsors-page'];
     if (isDark) classes.push('is-dark');
     if (isDark) classes.push('is-dark');
@@ -110,63 +73,17 @@ export default function SponsorsPage() {
 
 
               <Spin spinning={!fetched} delay={200}>
               <Spin spinning={!fetched} delay={200}>
                 {sponsors.length === 0 ? (
                 {sponsors.length === 0 ? (
-                  fetched && <YourBrandCard contact={data.contact} large />
+                  fetched && <YourBrandCard contact={data.contact} />
                 ) : (
                 ) : (
                   <Row gutter={[16, 16]}>
                   <Row gutter={[16, 16]}>
                     {sponsors.map((sponsor) => (
                     {sponsors.map((sponsor) => (
-                      <Col key={sponsor.id} xs={24} sm={12} xl={8}>
+                      <Col key={sponsor.id} xs={24} lg={12}>
                         <SponsorCard sponsor={sponsor} variant="card" />
                         <SponsorCard sponsor={sponsor} variant="card" />
                       </Col>
                       </Col>
                     ))}
                     ))}
-                    {data.contact && (
-                      <Col xs={24} sm={12} xl={8}>
-                        <YourBrandCard contact={data.contact} />
-                      </Col>
-                    )}
                   </Row>
                   </Row>
                 )}
                 )}
               </Spin>
               </Spin>
-
-              <Typography.Title level={5} className="sponsors-section-title">
-                {t('pages.sponsors.placements')}
-              </Typography.Title>
-              <Row gutter={[16, 16]}>
-                {placements.map((p) => {
-                  const status = placementStatus(data.sponsors, p.slot);
-                  return (
-                    <Col key={p.slot} xs={24} sm={12} xl={6}>
-                      <div className="sponsors-placement">
-                        <span className="sponsors-placement-icon">{p.icon}</span>
-                        <div className="sponsors-placement-body">
-                          <div className="sponsors-placement-title">{p.title}</div>
-                          <div className="sponsors-placement-desc">{p.desc}</div>
-                          <div className="sponsors-placement-status">
-                            {status.takenUntil ? (
-                              <Tag color="orange">
-                                {t('pages.sponsors.takenUntil', {
-                                  date: IntlUtil.formatDate(status.takenUntil, datepicker),
-                                })}
-                              </Tag>
-                            ) : (
-                              <Tag color="green">{t('pages.sponsors.available')}</Tag>
-                            )}
-                            {status.count > 0 && !status.takenUntil && (
-                              <Tag>
-                                {t('pages.sponsors.activeCount', {
-                                  count:
-                                    status.capacity && status.capacity > 1
-                                      ? `${status.count}/${status.capacity}`
-                                      : status.count,
-                                })}
-                              </Tag>
-                            )}
-                          </div>
-                        </div>
-                      </div>
-                    </Col>
-                  );
-                })}
-              </Row>
             </div>
             </div>
           </Layout.Content>
           </Layout.Content>
         </Layout>
         </Layout>

+ 5 - 56
frontend/src/test/sponsors.test.tsx

@@ -1,12 +1,6 @@
 import { afterEach, describe, expect, test } from 'vitest';
 import { afterEach, describe, expect, test } from 'vitest';
 
 
-import {
-  SPONSOR_DISMISS_MS,
-  dismissSponsor,
-  isSponsorDismissed,
-  pickLocale,
-  placementStatus,
-} from '@/lib/sponsors';
+import { SPONSOR_DISMISS_MS, dismissSponsor, isSponsorDismissed, pickLocale } from '@/lib/sponsors';
 
 
 afterEach(() => {
 afterEach(() => {
   localStorage.clear();
   localStorage.clear();
@@ -33,10 +27,10 @@ describe('sponsor dismissal', () => {
   const t0 = 1_800_000_000_000;
   const t0 = 1_800_000_000_000;
 
 
   test('hides only the dismissed slot until the window elapses', () => {
   test('hides only the dismissed slot until the window elapses', () => {
-    dismissSponsor('acme', 'sidebar', t0);
-    expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
+    dismissSponsor('acme', 'login', t0);
+    expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
     expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
     expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
-    expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
+    expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS)).toBe(false);
   });
   });
 
 
   test('prunes expired entries on the next dismiss', () => {
   test('prunes expired entries on the next dismiss', () => {
@@ -48,51 +42,6 @@ describe('sponsor dismissal', () => {
 
 
   test('corrupt storage is treated as nothing dismissed', () => {
   test('corrupt storage is treated as nothing dismissed', () => {
     localStorage.setItem('xui.sponsor.dismissed', 'not json');
     localStorage.setItem('xui.sponsor.dismissed', 'not json');
-    expect(isSponsorDismissed('acme', 'sidebar', t0)).toBe(false);
-  });
-});
-
-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,
-    });
+    expect(isSponsorDismissed('acme', 'login', t0)).toBe(false);
   });
   });
 });
 });

+ 32 - 0
internal/web/service/client_flow_isolation_test.go

@@ -324,3 +324,35 @@ func TestBulkAttach_PreservesVisionFlowWhenCanonicalColumnZeroed(t *testing.T) {
 		t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList)
 		t.Errorf("bulk-attached non-flow inbound must not receive Vision flow, got %#v", wsList)
 	}
 	}
 }
 }
+
+// A client detached from its last inbound has no effective flow left to settle
+// its record to, so the record keeps the flow a later attach falls back on.
+func TestRecordFlow_DetachedFromLastInboundKeepsFlow(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
+
+	reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 43001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
+	if err := database.GetDB().Create(reality).Error; err != nil {
+		t.Fatalf("create reality inbound: %v", err)
+	}
+
+	svc := ClientService{}
+	const email = "[email protected]"
+	const vision = "xtls-rprx-vision"
+	client := model.Client{Email: email, ID: "ce8d33df-3a64-4f10-8f9b-91c3a8e0c333", Enable: true, Flow: vision}
+	if err := svc.SyncInbound(nil, reality.Id, []model.Client{client}); err != nil {
+		t.Fatalf("SyncInbound(attach): %v", err)
+	}
+	if err := svc.SyncInbound(nil, reality.Id, nil); err != nil {
+		t.Fatalf("SyncInbound(detach): %v", err)
+	}
+
+	rec, err := svc.GetRecordByEmail(nil, email)
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.Flow != vision {
+		t.Errorf("record flow after detaching its last inbound = %q, want %q", rec.Flow, vision)
+	}
+}

+ 114 - 16
internal/web/service/client_link.go

@@ -105,7 +105,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 		tx = database.GetDB()
 		tx = database.GetDB()
 	}
 	}
 
 
-	if err := s.validateProtocolClients(tx, inboundId, clients, detachEmails, prune); err != nil {
+	var inbound model.Inbound
+	if err := tx.Select("protocol").Where("id = ?", inboundId).Take(&inbound).Error; err != nil {
+		return err
+	}
+	if err := s.validateProtocolClients(tx, inbound.Protocol, inboundId, clients, detachEmails, prune); err != nil {
 		return err
 		return err
 	}
 	}
 
 
@@ -137,6 +141,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 		}
 		}
 	}
 	}
 
 
+	isTunnel := inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG
+	var onLowerTunnel map[int]struct{}
+	if isTunnel && len(existing) > 0 {
+		ids := make([]int, 0, len(existing))
+		for _, row := range existing {
+			ids = append(ids, row.Id)
+		}
+		var err error
+		if onLowerTunnel, err = clientsOnLowerTunnel(tx, inboundId, ids); err != nil {
+			return err
+		}
+	}
+
 	idByEmail := make(map[string]int, len(emails))
 	idByEmail := make(map[string]int, len(emails))
 	pending := make(map[string]*model.ClientRecord, len(emails))
 	pending := make(map[string]*model.ClientRecord, len(emails))
 	toCreate := make([]*model.ClientRecord, 0, len(emails))
 	toCreate := make([]*model.ClientRecord, 0, len(emails))
@@ -161,6 +178,8 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 
 
 		before := *row
 		before := *row
 		applyClientRecordMerge(row, incoming)
 		applyClientRecordMerge(row, incoming)
+		_, outranked := onLowerTunnel[row.Id]
+		keepLinkOwnedFields(row, &before, isTunnel && !outranked)
 		preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
 		preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
 		row.UpdatedAt = preservedUpdatedAt
 		row.UpdatedAt = preservedUpdatedAt
 
 
@@ -222,7 +241,11 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 		wantedIds = append(wantedIds, id)
 		wantedIds = append(wantedIds, id)
 	}
 	}
 
 
-	if err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune); err != nil {
+	relinked, err := s.reconcileInboundLinks(tx, inboundId, wantedFlow, wantedIds, detachEmails, prune)
+	if err != nil {
+		return err
+	}
+	if err := settleRecordFlows(tx, relinked); err != nil {
 		return err
 		return err
 	}
 	}
 
 
@@ -260,19 +283,19 @@ func (s *ClientService) syncInboundClients(tx *gorm.DB, inboundId int, clients [
 	return nil
 	return nil
 }
 }
 
 
-// reconcileInboundLinks writes only the client_inbounds rows that differ. prune
-// also removes links absent from wantedFlow, which only a full sync may do.
-func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) error {
+// reconcileInboundLinks writes only the client_inbounds rows that differ and returns
+// their clients. prune also removes links absent from wantedFlow, as only a full sync may.
+func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wantedFlow map[int]string, wantedIds []int, detachEmails []string, prune bool) ([]int, error) {
 	var current []model.ClientInbound
 	var current []model.ClientInbound
 	if prune {
 	if prune {
 		if err := tx.Where("inbound_id = ?", inboundId).Find(&current).Error; err != nil {
 		if err := tx.Where("inbound_id = ?", inboundId).Find(&current).Error; err != nil {
-			return err
+			return nil, err
 		}
 		}
 	} else {
 	} else {
 		for _, batch := range chunkInts(wantedIds, sqlInChunk) {
 		for _, batch := range chunkInts(wantedIds, sqlInChunk) {
 			var rows []model.ClientInbound
 			var rows []model.ClientInbound
 			if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
 			if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).Find(&rows).Error; err != nil {
-				return err
+				return nil, err
 			}
 			}
 			current = append(current, rows...)
 			current = append(current, rows...)
 		}
 		}
@@ -300,7 +323,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
 		for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
 		for _, batch := range chunkStrings(detachEmails, sqlInChunk) {
 			var ids []int
 			var ids []int
 			if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
 			if err := tx.Model(&model.ClientRecord{}).Where("email IN ?", batch).Pluck("id", &ids).Error; err != nil {
-				return err
+				return nil, err
 			}
 			}
 			for _, id := range ids {
 			for _, id := range ids {
 				if _, keep := wantedFlow[id]; !keep {
 				if _, keep := wantedFlow[id]; !keep {
@@ -325,7 +348,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
 	for _, batch := range chunkInts(toDelete, sqlInChunk) {
 	for _, batch := range chunkInts(toDelete, sqlInChunk) {
 		if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
 		if err := tx.Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
 			Delete(&model.ClientInbound{}).Error; err != nil {
 			Delete(&model.ClientInbound{}).Error; err != nil {
-			return err
+			return nil, err
 		}
 		}
 	}
 	}
 	for flow, ids := range toUpdate {
 	for flow, ids := range toUpdate {
@@ -333,7 +356,7 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
 			if err := tx.Model(&model.ClientInbound{}).
 			if err := tx.Model(&model.ClientInbound{}).
 				Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
 				Where("inbound_id = ? AND client_id IN ?", inboundId, batch).
 				Update("flow_override", flow).Error; err != nil {
 				Update("flow_override", flow).Error; err != nil {
-				return err
+				return nil, err
 			}
 			}
 		}
 		}
 	}
 	}
@@ -344,8 +367,87 @@ func (s *ClientService) reconcileInboundLinks(tx *gorm.DB, inboundId int, wanted
 			Columns:   []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
 			Columns:   []clause.Column{{Name: "client_id"}, {Name: "inbound_id"}},
 			DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
 			DoUpdates: clause.AssignmentColumns([]string{"flow_override"}),
 		}).CreateInBatches(toInsert, 200).Error; err != nil {
 		}).CreateInBatches(toInsert, 200).Error; err != nil {
+			return nil, err
+		}
+	}
+
+	relinked := append([]int(nil), toDelete...)
+	for _, ids := range toUpdate {
+		relinked = append(relinked, ids...)
+	}
+	for _, link := range toInsert {
+		relinked = append(relinked, link.ClientId)
+	}
+	return relinked, nil
+}
+
+// keepLinkOwnedFields undoes the merge of the record fields one inbound cannot decide
+// alone, or syncing a client's inbounds in turn rewrites the record back and forth.
+func keepLinkOwnedFields(row, before *model.ClientRecord, ownsTunnelPeer bool) {
+	row.Flow = before.Flow
+	if ownsTunnelPeer {
+		return
+	}
+	row.PrivateKey = before.PrivateKey
+	row.PublicKey = before.PublicKey
+	row.AllowedIPs = before.AllowedIPs
+	row.PreSharedKey = before.PreSharedKey
+	row.KeepAlive = before.KeepAlive
+}
+
+// clientsOnLowerTunnel returns the clients also attached to a WireGuard/AmneziaWG
+// inbound with a lower id than inboundId: that tunnel owns their record's peer fields.
+func clientsOnLowerTunnel(tx *gorm.DB, inboundId int, clientIds []int) (map[int]struct{}, error) {
+	out := make(map[int]struct{})
+	for _, batch := range chunkInts(clientIds, sqlInChunk) {
+		var ids []int
+		if err := tx.Table("client_inbounds").
+			Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id").
+			Where("client_inbounds.client_id IN ? AND client_inbounds.inbound_id < ? AND inbounds.protocol IN ?",
+				batch, inboundId, []model.Protocol{model.WireGuard, model.AmneziaWG}).
+			Distinct().Pluck("client_inbounds.client_id", &ids).Error; err != nil {
+			return nil, err
+		}
+		for _, id := range ids {
+			out[id] = struct{}{}
+		}
+	}
+	return out, nil
+}
+
+// settleRecordFlows sets the record flow of each still-linked client to its
+// effective flow (EffectiveFlow's rule); an unlinked record keeps its own.
+func settleRecordFlows(tx *gorm.DB, clientIds []int) error {
+	for _, batch := range chunkInts(clientIds, sqlInChunk) {
+		var links []model.ClientInbound
+		if err := tx.Select("client_id", "flow_override").
+			Where("client_id IN ?", batch).
+			Order("inbound_id ASC").
+			Find(&links).Error; err != nil {
 			return err
 			return err
 		}
 		}
+		effective := make(map[int]string, len(batch))
+		for _, link := range links {
+			if flow, seen := effective[link.ClientId]; !seen || (flow == "" && link.FlowOverride != "") {
+				effective[link.ClientId] = link.FlowOverride
+			}
+		}
+		var records []model.ClientRecord
+		if err := tx.Select("id", "flow").Where("id IN ?", batch).Find(&records).Error; err != nil {
+			return err
+		}
+		idsByFlow := make(map[string][]int)
+		for _, rec := range records {
+			if flow, linked := effective[rec.Id]; linked && flow != rec.Flow {
+				idsByFlow[flow] = append(idsByFlow[flow], rec.Id)
+			}
+		}
+		for flow, ids := range idsByFlow {
+			if err := tx.Model(&model.ClientRecord{}).Where("id IN ?", ids).
+				UpdateColumn("flow", flow).Error; err != nil {
+				return err
+			}
+		}
 	}
 	}
 	return nil
 	return nil
 }
 }
@@ -418,12 +520,8 @@ func (s *ClientService) ListForInboundBySubId(tx *gorm.DB, inboundId int, subId
 
 
 // validateProtocolClients runs inside the write every path linking clients to an inbound
 // validateProtocolClients runs inside the write every path linking clients to an inbound
 // shares, so a client the protocol's core config would refuse never reaches it.
 // shares, so a client the protocol's core config would refuse never reaches it.
-func (s *ClientService) validateProtocolClients(tx *gorm.DB, inboundID int, changed []model.Client, detached []string, prune bool) error {
-	var inbound model.Inbound
-	if err := tx.Select("protocol").Where("id = ?", inboundID).Take(&inbound).Error; err != nil {
-		return err
-	}
-	switch inbound.Protocol {
+func (s *ClientService) validateProtocolClients(tx *gorm.DB, protocol model.Protocol, inboundID int, changed []model.Client, detached []string, prune bool) error {
+	switch protocol {
 	case model.TUIC:
 	case model.TUIC:
 		return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
 		return s.validateTuicIdentities(tx, inboundID, changed, detached, prune)
 	case model.MASQUE:
 	case model.MASQUE:

+ 75 - 62
internal/web/service/inbound_node.go

@@ -224,6 +224,43 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
 	}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
 	}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
 }
 }
 
 
+// mergeNodeClientTraffic folds one node-reported client into its central row.
+// While frozen (a push pending or just landed) only the counters may move.
+func (s *InboundService) mergeNodeClientTraffic(tx *gorm.DB, cs xray.ClientTraffic, frozen bool, deltaUp, deltaDown, now int64) error {
+	if frozen {
+		return tx.Exec(
+			fmt.Sprintf(
+				`UPDATE client_traffics
+				 SET up = %s, down = %s, last_online = %s
+				 WHERE email = ?`,
+				database.ClampedAddExpr("up"),
+				database.ClampedAddExpr("down"),
+				database.GreatestExpr("last_online", "?"),
+			),
+			deltaUp, deltaDown, cs.LastOnline, cs.Email,
+		).Error
+	}
+	return tx.Exec(
+		fmt.Sprintf(
+			`UPDATE client_traffics
+			 SET up = %s, down = %s, enable = %s, total = ?,
+			     expiry_time = %s,
+			     reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
+			 WHERE email = ?`,
+			database.ClampedAddExpr("up"),
+			database.ClampedAddExpr("down"),
+			database.ClientTrafficEnableMergeExpr(),
+			database.ClientTrafficExpiryMergeExpr(),
+			database.GreatestExpr("last_online", "?"),
+		),
+		deltaUp, deltaDown,
+		cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
+		cs.Total,
+		cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
+		cs.LastOnline, cs.Email,
+	).Error
+}
+
 // mergeActivationExpiry: master absolute wins; node may only activate when
 // mergeActivationExpiry: master absolute wins; node may only activate when
 // master is unset/duration. Node auto-renew goes through nodeClientRenewed.
 // master is unset/duration. Node auto-renew goes through nodeClientRenewed.
 func mergeActivationExpiry(existing, node int64) int64 {
 func mergeActivationExpiry(existing, node int64) int64 {
@@ -634,22 +671,25 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 		}
 	}
 	}
 
 
-	// Membership set for the rowExists checks below. Only the snapshot's emails
-	// are ever probed, so scope the lookup to those instead of plucking the whole
-	// client_traffics table (50k+ rows) on every node poll.
-	existingEmails := make(map[string]struct{}, len(snapEmailsAll))
+	// The stored row of every reported email, wherever it is homed, so the merge
+	// can skip a write that would leave it unchanged. Homed rows share centralCS's pointer.
+	statByEmail := make(map[string]*xray.ClientTraffic, len(snapEmailsAll))
 	if len(snapEmailsAll) > 0 {
 	if len(snapEmailsAll) > 0 {
 		snapEmailList := make([]string, 0, len(snapEmailsAll))
 		snapEmailList := make([]string, 0, len(snapEmailsAll))
 		for email := range snapEmailsAll {
 		for email := range snapEmailsAll {
 			snapEmailList = append(snapEmailList, email)
 			snapEmailList = append(snapEmailList, email)
 		}
 		}
 		for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
 		for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) {
-			var found []string
-			if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Pluck("email", &found).Error; err != nil {
+			var found []xray.ClientTraffic
+			if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&found).Error; err != nil {
 				return false, err
 				return false, err
 			}
 			}
-			for _, e := range found {
-				existingEmails[e] = struct{}{}
+			for i := range found {
+				if homed, ok := centralCSByEmail[found[i].Email]; ok {
+					statByEmail[found[i].Email] = homed
+					continue
+				}
+				statByEmail[found[i].Email] = &found[i]
 			}
 			}
 		}
 		}
 	}
 	}
@@ -1024,7 +1064,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				}
 				}
 			}
 			}
 
 
-			if _, rowExists := existingEmails[cs.Email]; !rowExists {
+			if _, rowExists := statByEmail[cs.Email]; !rowExists {
 				if dirty {
 				if dirty {
 					continue
 					continue
 				}
 				}
@@ -1062,7 +1102,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				}
 				}
 				centralCS[csKey{c.Id, cs.Email}] = row
 				centralCS[csKey{c.Id, cs.Email}] = row
 				centralCSByEmail[cs.Email] = row
 				centralCSByEmail[cs.Email] = row
-				existingEmails[cs.Email] = struct{}{}
+				statByEmail[cs.Email] = row
 				structuralChange = true
 				structuralChange = true
 				if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
 				if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
 					return false, err
 					return false, err
@@ -1121,64 +1161,34 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 				existing.Total = cs.Total
 				existing.Total = cs.Total
 				existing.ExpiryTime = cs.ExpiryTime
 				existing.ExpiryTime = cs.ExpiryTime
 				existing.Reset = cs.Reset
 				existing.Reset = cs.Reset
+				existing.ResetDay = cs.ResetDay
 				existing.ResetWeekday = cs.ResetWeekday
 				existing.ResetWeekday = cs.ResetWeekday
 				existing.ResetCount = cs.ResetCount
 				existing.ResetCount = cs.ResetCount
+				existing.LastOnline = max(existing.LastOnline, cs.LastOnline)
 				structuralChange = true
 				structuralChange = true
-			} else if clientFrozen {
-				// Push pending or just landed: only counters may move, the master
-				// keeps expiry/enable/total/reset.
-				if err := tx.Exec(
-					fmt.Sprintf(
-						`UPDATE client_traffics
-						 SET up = %s, down = %s, last_online = %s
-						 WHERE email = ?`,
-						database.ClampedAddExpr("up"),
-						database.ClampedAddExpr("down"),
-						database.GreatestExpr("last_online", "?"),
-					),
-					deltaUp, deltaDown, cs.LastOnline, cs.Email,
-				).Error; err != nil {
-					return false, err
-				}
-				if existing != nil {
-					existing.Up = clampTrafficCounter(existing.Up + deltaUp)
-					existing.Down = clampTrafficCounter(existing.Down + deltaDown)
-				}
 			} else {
 			} else {
-				enableExpr := database.ClientTrafficEnableMergeExpr()
-				expiryExpr := database.ClientTrafficExpiryMergeExpr()
-				if err := tx.Exec(
-					fmt.Sprintf(
-						`UPDATE client_traffics
-						 SET up = %s, down = %s, enable = %s, total = ?,
-						     expiry_time = %s,
-						     reset = ?, reset_day = ?, reset_weekday = ?, last_online = %s
-						 WHERE email = ?`,
-						database.ClampedAddExpr("up"),
-						database.ClampedAddExpr("down"),
-						enableExpr,
-						expiryExpr,
-						database.GreatestExpr("last_online", "?"),
-					),
-					deltaUp, deltaDown,
-					cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown,
-					cs.Total,
-					cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetWeekday,
-					cs.LastOnline, cs.Email,
-				).Error; err != nil {
-					return false, err
+				// The row as the UPDATE below would leave it, computed from its
+				// pre-update values exactly as the SQL reads them.
+				row := statByEmail[cs.Email]
+				next := *row
+				next.Up = clampTrafficCounter(row.Up + deltaUp)
+				next.Down = clampTrafficCounter(row.Down + deltaDown)
+				next.LastOnline = max(row.LastOnline, cs.LastOnline)
+				if !clientFrozen {
+					if !cs.Enable && !nodeDisableIsStale(row, cs, now, deltaUp, deltaDown) {
+						next.Enable = false
+					}
+					next.ExpiryTime = mergeActivationExpiry(row.ExpiryTime, cs.ExpiryTime)
+					next.Total = cs.Total
+					next.Reset, next.ResetDay, next.ResetWeekday = cs.Reset, cs.ResetDay, cs.ResetWeekday
 				}
 				}
-				if existing != nil {
-					priorExpiry := existing.ExpiryTime
-					if !cs.Enable && !nodeDisableIsStale(existing, cs, now, deltaUp, deltaDown) {
-						existing.Enable = false
+				// A multi-attached client is reported once per inbound and an idle one
+				// every tick: rewriting an unchanged row only holds its lock longer.
+				if next != *row {
+					if err := s.mergeNodeClientTraffic(tx, cs, clientFrozen, deltaUp, deltaDown, now); err != nil {
+						return false, err
 					}
 					}
-					existing.ExpiryTime = mergeActivationExpiry(priorExpiry, cs.ExpiryTime)
-					existing.Up = clampTrafficCounter(existing.Up + deltaUp)
-					existing.Down = clampTrafficCounter(existing.Down + deltaDown)
-					existing.Total = cs.Total
-					existing.Reset = cs.Reset
-					existing.ResetWeekday = cs.ResetWeekday
+					*row = next
 				}
 				}
 			}
 			}
 			// A dip plus a lagging longer expiry mimics nodeClientRenewed and would
 			// A dip plus a lagging longer expiry mimics nodeClientRenewed and would
@@ -1186,6 +1196,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) {
 			if clientFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) {
 				continue
 				continue
 			}
 			}
+			if seen && base == canon {
+				continue
+			}
 			if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
 			if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil {
 				return false, err
 				return false, err
 			}
 			}

+ 162 - 0
internal/web/service/node_client_traffic_sum_test.go

@@ -1,9 +1,14 @@
 package service
 package service
 
 
 import (
 import (
+	"encoding/json"
 	"fmt"
 	"fmt"
+	"maps"
 	"path/filepath"
 	"path/filepath"
+	"regexp"
+	"slices"
 	"strings"
 	"strings"
+	"sync"
 	"testing"
 	"testing"
 
 
 	"gorm.io/gorm"
 	"gorm.io/gorm"
@@ -471,6 +476,163 @@ func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) {
 	assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
 	assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment")
 }
 }
 
 
+var clientWriteSQL = regexp.MustCompile(`(?i)^\s*(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+["` + "`" + `]?(clients|client_inbounds|client_traffics|node_client_traffics)\b`)
+
+// countClientRowWrites returns, per client table, how many rows fn's
+// INSERT/UPDATE/DELETE statements touched.
+func countClientRowWrites(t *testing.T, fn func()) map[string]int64 {
+	t.Helper()
+	db := database.GetDB()
+	var mu sync.Mutex
+	rows := map[string]int64{}
+	record := func(tx *gorm.DB) {
+		if tx.Error != nil || tx.RowsAffected == 0 || tx.Statement == nil {
+			return
+		}
+		if m := clientWriteSQL.FindStringSubmatch(tx.Statement.SQL.String()); m != nil {
+			mu.Lock()
+			rows[m[1]] += tx.RowsAffected
+			mu.Unlock()
+		}
+	}
+	const name = "test:count_client_row_writes"
+	cb := db.Callback()
+	for _, err := range []error{
+		cb.Create().After("gorm:create").Register(name, record),
+		cb.Update().After("gorm:update").Register(name, record),
+		cb.Delete().After("gorm:delete").Register(name, record),
+		cb.Raw().After("gorm:raw").Register(name, record),
+	} {
+		if err != nil {
+			t.Fatalf("register write callback: %v", err)
+		}
+	}
+	defer func() {
+		for _, err := range []error{
+			cb.Create().Remove(name), cb.Update().Remove(name),
+			cb.Delete().Remove(name), cb.Raw().Remove(name),
+		} {
+			if err != nil {
+				t.Errorf("remove write callback: %v", err)
+			}
+		}
+	}()
+	fn()
+	return rows
+}
+
+func TestNodeMerge_WritesOnlyChangedClients(t *testing.T) {
+	const clientCount = 20
+	type snapInbound struct {
+		tag      string
+		protocol model.Protocol
+		entry    func(i int, email string) map[string]any
+	}
+	vlessEntry := func(flow string) func(int, string) map[string]any {
+		return func(i int, email string) map[string]any {
+			e := map[string]any{"email": email, "id": fmt.Sprintf("%08d-0000-4000-8000-000000000000", i), "subId": email, "enable": true}
+			if flow != "" {
+				e["flow"] = flow
+			}
+			return e
+		}
+	}
+	wgEntry := func(tunnel int) func(int, string) map[string]any {
+		return func(i int, email string) map[string]any {
+			return map[string]any{
+				"email": email, "subId": email, "enable": true,
+				"privateKey":   fmt.Sprintf("priv-%d-%d", tunnel, i),
+				"publicKey":    fmt.Sprintf("pub-%d-%d", tunnel, i),
+				"allowedIPs":   []string{fmt.Sprintf("10.%d.0.%d/32", tunnel, i+2)},
+				"preSharedKey": fmt.Sprintf("psk-%d-%d", tunnel, i),
+				"keepAlive":    25 * tunnel,
+			}
+		}
+	}
+
+	cases := []struct {
+		name     string
+		inbounds []snapInbound
+	}{
+		{"vless with and without flow", []snapInbound{
+			{"n1-reality", model.VLESS, vlessEntry("xtls-rprx-vision")},
+			{"n1-ws", model.VLESS, vlessEntry("")},
+		}},
+		{"wireguard and vless", []snapInbound{
+			{"n1-wg", model.WireGuard, wgEntry(1)},
+			{"n1-vless", model.VLESS, vlessEntry("")},
+		}},
+		{"two wireguard tunnels", []snapInbound{
+			{"n1-wg-a", model.WireGuard, wgEntry(1)},
+			{"n1-wg-b", model.WireGuard, wgEntry(2)},
+		}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			db := initTrafficTestDB(t)
+			svc := &InboundService{}
+
+			emails := make([]string, clientCount)
+			stats := make([]xray.ClientTraffic, clientCount)
+			for i := range emails {
+				emails[i] = fmt.Sprintf("multi-%d", i)
+				stats[i] = xray.ClientTraffic{Email: emails[i], Up: 1000, Down: 2000, Enable: true, LastOnline: 5000}
+			}
+			settings := make([]string, len(tc.inbounds))
+			for n, ib := range tc.inbounds {
+				entries := make([]map[string]any, clientCount)
+				for i, email := range emails {
+					entries[i] = ib.entry(i, email)
+				}
+				raw, err := json.Marshal(map[string]any{"clients": entries})
+				if err != nil {
+					t.Fatalf("marshal settings: %v", err)
+				}
+				settings[n] = string(raw)
+				nodeID := 1
+				if err := db.Create(&model.Inbound{
+					UserId: 1, NodeID: &nodeID, Tag: ib.tag, Enable: true,
+					Port: 42000 + n, Protocol: ib.protocol, Settings: settings[n],
+				}).Error; err != nil {
+					t.Fatalf("create inbound %q: %v", ib.tag, err)
+				}
+			}
+			merge := func(stats []xray.ClientTraffic) {
+				t.Helper()
+				// A node copies a multi-attached client's one counter onto every inbound it is on.
+				snap := &runtime.TrafficSnapshot{}
+				for n, ib := range tc.inbounds {
+					snap.Inbounds = append(snap.Inbounds, &model.Inbound{
+						Tag: ib.tag, Enable: true, Port: 42000 + n, Protocol: ib.protocol,
+						Settings: settings[n], ClientStats: slices.Clone(stats),
+					})
+				}
+				if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
+					t.Fatalf("setRemoteTrafficLocked: %v", err)
+				}
+			}
+
+			merge(stats)
+			if got := countClientRowWrites(t, func() { merge(stats) }); len(got) != 0 {
+				t.Errorf("repeating an unchanged snapshot wrote client rows %v, want none", got)
+			}
+
+			active := slices.Clone(stats)
+			active[0].Up, active[0].Down, active[0].LastOnline = 1300, 2600, 6000
+			got := countClientRowWrites(t, func() { merge(active) })
+			want := map[string]int64{"client_traffics": 1, "node_client_traffics": 1}
+			if !maps.Equal(got, want) {
+				t.Errorf("one client's traffic tick wrote client rows %v, want %v", got, want)
+			}
+			ct := readTraffic(t, db, emails[0])
+			assertUpDown(t, ct, 300, 600, "active client after its tick")
+			if ct.LastOnline != 6000 {
+				t.Errorf("last_online = %d, want 6000", ct.LastOnline)
+			}
+		})
+	}
+}
+
 func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
 func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
 	db := initTrafficTestDB(t)
 	db := initTrafficTestDB(t)
 	svc := &InboundService{}
 	svc := &InboundService{}

+ 3 - 10
internal/web/service/panel/sponsor.go

@@ -45,7 +45,6 @@ const (
 	sponsorsErrTTL   = 10 * time.Minute
 	sponsorsErrTTL   = 10 * time.Minute
 	maxSponsorsBytes = 256 << 10
 	maxSponsorsBytes = 256 << 10
 	maxLogoBytes     = 256 << 10
 	maxLogoBytes     = 256 << 10
-	maxSidebarSlots  = 3
 	localSponsorsDir = "../sponsors/3X"
 	localSponsorsDir = "../sponsors/3X"
 	sponsorLogoPath  = "/sponsors/logo/"
 	sponsorLogoPath  = "/sponsors/logo/"
 )
 )
@@ -67,7 +66,7 @@ var (
 
 
 	// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
 	// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
 	sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
 	sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
-	sponsorSlots  = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
+	sponsorSlots  = map[string]bool{"dashboard": true, "page": true, "login": true}
 
 
 	sponsorsMu      sync.Mutex
 	sponsorsMu      sync.Mutex
 	sponsorsRaw     *SponsorList
 	sponsorsRaw     *SponsorList
@@ -217,7 +216,6 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
 	if strings.HasPrefix(raw.Contact, "https://") {
 	if strings.HasPrefix(raw.Contact, "https://") {
 		out.Contact = raw.Contact
 		out.Contact = raw.Contact
 	}
 	}
-	sidebarTaken := 0
 	for _, sp := range raw.Sponsors {
 	for _, sp := range raw.Sponsors {
 		// A missing enable counts as on, so a forgotten field never hides a paid slot.
 		// A missing enable counts as on, so a forgotten field never hides a paid slot.
 		disabled := sp.Enable != nil && !*sp.Enable
 		disabled := sp.Enable != nil && !*sp.Enable
@@ -233,18 +231,13 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
 		}
 		}
 		slots := make([]string, 0, len(sp.Slots))
 		slots := make([]string, 0, len(sp.Slots))
 		for _, slot := range sp.Slots {
 		for _, slot := range sp.Slots {
-			// The sidebar rotates, so capping it keeps each paid card on screen long enough.
-			if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
-				continue
+			if sponsorSlots[slot] {
+				slots = append(slots, slot)
 			}
 			}
-			slots = append(slots, slot)
 		}
 		}
 		if len(slots) == 0 {
 		if len(slots) == 0 {
 			continue
 			continue
 		}
 		}
-		if slices.Contains(slots, "sidebar") {
-			sidebarTaken++
-		}
 		sp.Slots = slots
 		sp.Slots = slots
 		out.Sponsors = append(out.Sponsors, sp)
 		out.Sponsors = append(out.Sponsors, sp)
 	}
 	}

+ 2 - 31
internal/web/service/panel/sponsor_test.go

@@ -6,7 +6,6 @@ import (
 	"net/http/httptest"
 	"net/http/httptest"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
-	"slices"
 	"strings"
 	"strings"
 	"sync/atomic"
 	"sync/atomic"
 	"testing"
 	"testing"
@@ -22,7 +21,7 @@ func validSponsor() Sponsor {
 	return Sponsor{
 	return Sponsor{
 		ID:    "acme",
 		ID:    "acme",
 		Name:  "Acme",
 		Name:  "Acme",
-		Slots: []string{"dashboard", "sidebar"},
+		Slots: []string{"dashboard"},
 		Until: sponsorTestNow.Add(24 * time.Hour),
 		Until: sponsorTestNow.Add(24 * time.Hour),
 		Logo:  "acme.png",
 		Logo:  "acme.png",
 		Link:  "https://acme.example/",
 		Link:  "https://acme.example/",
@@ -59,34 +58,6 @@ func TestActiveSponsorsFilters(t *testing.T) {
 	}
 	}
 }
 }
 
 
-func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) {
-	raw := &SponsorList{}
-	for _, id := range []string{"expired", "a", "b", "c", "d", "e"} {
-		sp := validSponsor()
-		sp.ID = id
-		sp.Slots = []string{"sidebar", "page"}
-		if id == "expired" {
-			sp.Until = sponsorTestNow
-		}
-		if id == "e" {
-			sp.Slots = []string{"sidebar"}
-		}
-		raw.Sponsors = append(raw.Sponsors, sp)
-	}
-	got := activeSponsors(raw, sponsorTestNow)
-	want := map[string][]string{
-		"a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"},
-	}
-	if len(got.Sponsors) != len(want) {
-		t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want))
-	}
-	for _, sp := range got.Sponsors {
-		if !slices.Equal(sp.Slots, want[sp.ID]) {
-			t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID])
-		}
-	}
-}
-
 func TestActiveSponsorsLogoName(t *testing.T) {
 func TestActiveSponsorsLogoName(t *testing.T) {
 	cases := []struct{ logo, want string }{
 	cases := []struct{ logo, want string }{
 		{"acme.png", "/sponsors/logo/acme.png"},
 		{"acme.png", "/sponsors/logo/acme.png"},
@@ -114,7 +85,7 @@ func TestActiveSponsorsLogoName(t *testing.T) {
 
 
 func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
 func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
 	sp := validSponsor()
 	sp := validSponsor()
-	sp.Slots = []string{"subpage", "login"}
+	sp.Slots = []string{"subpage", "sidebar", "login"}
 	got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
 	got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
 	if len(got.Sponsors) != 1 {
 	if len(got.Sponsors) != 1 {
 		t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
 		t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))

+ 5 - 4
internal/web/service/xray_wireguard_config_test.go

@@ -94,12 +94,13 @@ func seedDualTunnelClient(t *testing.T, enabled bool, wgKeepAlive *int) string {
 
 
 	wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)}
 	wgInbound := &model.Inbound{Tag: "wg-dual", Enable: true, Port: 51823, Protocol: model.WireGuard, Settings: string(wgSettings)}
 	awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
 	awgInbound := &model.Inbound{Tag: "awg-dual", Enable: true, Port: 51824, Protocol: model.AmneziaWG, Settings: string(awgSettings)}
-	if err := db.Create(wgInbound).Error; err != nil {
-		t.Fatalf("create wg inbound: %v", err)
-	}
+	// The lower-id tunnel owns the shared record's peer fields: AmneziaWG first.
 	if err := db.Create(awgInbound).Error; err != nil {
 	if err := db.Create(awgInbound).Error; err != nil {
 		t.Fatalf("create awg inbound: %v", err)
 		t.Fatalf("create awg inbound: %v", err)
 	}
 	}
+	if err := db.Create(wgInbound).Error; err != nil {
+		t.Fatalf("create wg inbound: %v", err)
+	}
 
 
 	svc := ClientService{}
 	svc := ClientService{}
 	if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil {
 	if err := svc.SyncInbound(nil, wgInbound.Id, []model.Client{wgClient}); err != nil {
@@ -202,7 +203,7 @@ func TestGetXrayConfigWireGuardUsesInboundLocalTunnelFields(t *testing.T) {
 		t.Fatalf("read shared client: %v", err)
 		t.Fatalf("read shared client: %v", err)
 	}
 	}
 	if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
 	if shared.AllowedIPs != "10.8.1.5/32" || shared.PreSharedKey != "awg-psk" || shared.PublicKey != "pub-awg" || shared.KeepAlive != 25 {
-		t.Fatalf("test setup did not persist AmneziaWG last: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
+		t.Fatalf("test setup did not persist AmneziaWG identity: allowedIPs=%q preSharedKey=%q publicKey=%q keepAlive=%d", shared.AllowedIPs, shared.PreSharedKey, shared.PublicKey, shared.KeepAlive)
 	}
 	}
 
 
 	peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))
 	peers := wgPeerList(t, wgInboundEmittedSettings(t, "wg-dual"))

+ 1 - 13
internal/web/translation/ar-EG.json

@@ -2423,19 +2423,7 @@
       "tag": "راعٍ",
       "tag": "راعٍ",
       "visit": "زيارة",
       "visit": "زيارة",
       "yourBrand": "علامتك التجارية هنا",
       "yourBrand": "علامتك التجارية هنا",
-      "yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم.",
-      "placements": "المواضع المتاحة",
-      "placementDashboard": "لافتة لوحة المعلومات",
-      "placementDashboardDesc": "موضع مميز أعلى لوحة المعلومات، راعٍ واحد في كل مرة.",
-      "placementSidebar": "بطاقة الشريط الجانبي",
-      "placementSidebarDesc": "تظهر في كل صفحات اللوحة وتتناوب بين الرعاة.",
-      "placementLogin": "صفحة تسجيل الدخول",
-      "placementLoginDesc": "يراها المسؤول عند كل تسجيل دخول.",
-      "placementPage": "صفحة الرعاة",
-      "placementPageDesc": "مدرجة هنا مع الشعار والوصف والرابط.",
-      "available": "متاح",
-      "takenUntil": "محجوز حتى {date}",
-      "activeCount": "{count} نشط"
+      "yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/en-US.json

@@ -2423,19 +2423,7 @@
       "tag": "Sponsor",
       "tag": "Sponsor",
       "visit": "Visit",
       "visit": "Visit",
       "yourBrand": "Your brand here",
       "yourBrand": "Your brand here",
-      "yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day.",
-      "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"
+      "yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/es-ES.json

@@ -2423,19 +2423,7 @@
       "tag": "Patrocinador",
       "tag": "Patrocinador",
       "visit": "Visitar",
       "visit": "Visitar",
       "yourBrand": "Tu marca aquí",
       "yourBrand": "Tu marca aquí",
-      "yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día.",
-      "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"
+      "yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/fa-IR.json

@@ -2423,19 +2423,7 @@
       "tag": "اسپانسر",
       "tag": "اسپانسر",
       "visit": "مشاهده",
       "visit": "مشاهده",
       "yourBrand": "جای برند شما",
       "yourBrand": "جای برند شما",
-      "yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز می‌کنند.",
-      "placements": "جایگاه‌های موجود",
-      "placementDashboard": "بنر داشبورد",
-      "placementDashboardDesc": "جایگاه ویژه بالای صفحه‌ی داشبورد، هر بار فقط یک اسپانسر.",
-      "placementSidebar": "کارت سایدبار",
-      "placementSidebarDesc": "در همه‌ی صفحات پنل نمایش داده می‌شود و بین اسپانسرها چرخش دارد.",
-      "placementLogin": "صفحه‌ی ورود",
-      "placementLoginDesc": "ادمین در هر بار ورود آن را می‌بیند.",
-      "placementPage": "صفحه‌ی اسپانسرها",
-      "placementPageDesc": "همراه با لوگو، توضیحات و لینک در همین صفحه.",
-      "available": "خالی",
-      "takenUntil": "پر شده تا {date}",
-      "activeCount": "{count} فعال"
+      "yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز می‌کنند."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/id-ID.json

@@ -2423,19 +2423,7 @@
       "tag": "Sponsor",
       "tag": "Sponsor",
       "visit": "Kunjungi",
       "visit": "Kunjungi",
       "yourBrand": "Merek Anda di sini",
       "yourBrand": "Merek Anda di sini",
-      "yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari.",
-      "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"
+      "yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/ja-JP.json

@@ -2423,19 +2423,7 @@
       "tag": "スポンサー",
       "tag": "スポンサー",
       "visit": "訪問",
       "visit": "訪問",
       "yourBrand": "ここにあなたのブランドを",
       "yourBrand": "ここにあなたのブランドを",
-      "yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。",
-      "placements": "掲載枠",
-      "placementDashboard": "ダッシュボードバナー",
-      "placementDashboardDesc": "ダッシュボード上部のプレミアム枠。一度に 1 社のみ。",
-      "placementSidebar": "サイドバーカード",
-      "placementSidebarDesc": "パネルの全ページに表示され、スポンサー間でローテーションします。",
-      "placementLogin": "ログインページ",
-      "placementLoginDesc": "管理者がログインするたびに表示されます。",
-      "placementPage": "スポンサーページ",
-      "placementPageDesc": "ロゴ、説明、リンク付きでここに掲載されます。",
-      "available": "空きあり",
-      "takenUntil": "{date} まで予約済み",
-      "activeCount": "{count} 件掲載中"
+      "yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。"
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/pt-BR.json

@@ -2423,19 +2423,7 @@
       "tag": "Patrocinador",
       "tag": "Patrocinador",
       "visit": "Visitar",
       "visit": "Visitar",
       "yourBrand": "Sua marca aqui",
       "yourBrand": "Sua marca aqui",
-      "yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias.",
-      "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"
+      "yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/ru-RU.json

@@ -2423,19 +2423,7 @@
       "tag": "Спонсор",
       "tag": "Спонсор",
       "visit": "Перейти",
       "visit": "Перейти",
       "yourBrand": "Здесь может быть ваш бренд",
       "yourBrand": "Здесь может быть ваш бренд",
-      "yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI.",
-      "placements": "Доступные места",
-      "placementDashboard": "Баннер на главной",
-      "placementDashboardDesc": "Премиум-место вверху панели, один спонсор за раз.",
-      "placementSidebar": "Карточка в боковой панели",
-      "placementSidebarDesc": "Показывается на всех страницах панели, спонсоры чередуются.",
-      "placementLogin": "Страница входа",
-      "placementLoginDesc": "Администратор видит её при каждом входе.",
-      "placementPage": "Страница спонсоров",
-      "placementPageDesc": "Размещение здесь с логотипом, описанием и ссылкой.",
-      "available": "Свободно",
-      "takenUntil": "Занято до {date}",
-      "activeCount": "Активных: {count}"
+      "yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/tr-TR.json

@@ -2423,19 +2423,7 @@
       "tag": "Sponsor",
       "tag": "Sponsor",
       "visit": "Ziyaret et",
       "visit": "Ziyaret et",
       "yourBrand": "Markanız burada",
       "yourBrand": "Markanız burada",
-      "yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın.",
-      "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"
+      "yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/uk-UA.json

@@ -2423,19 +2423,7 @@
       "tag": "Спонсор",
       "tag": "Спонсор",
       "visit": "Перейти",
       "visit": "Перейти",
       "yourBrand": "Тут може бути ваш бренд",
       "yourBrand": "Тут може бути ваш бренд",
-      "yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI.",
-      "placements": "Доступні місця",
-      "placementDashboard": "Банер на головній",
-      "placementDashboardDesc": "Преміум-місце вгорі панелі, один спонсор за раз.",
-      "placementSidebar": "Картка в бічній панелі",
-      "placementSidebarDesc": "Показується на всіх сторінках панелі, спонсори чергуються.",
-      "placementLogin": "Сторінка входу",
-      "placementLoginDesc": "Адміністратор бачить її під час кожного входу.",
-      "placementPage": "Сторінка спонсорів",
-      "placementPageDesc": "Розміщення тут із логотипом, описом і посиланням.",
-      "available": "Вільно",
-      "takenUntil": "Зайнято до {date}",
-      "activeCount": "Активних: {count}"
+      "yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/vi-VN.json

@@ -2423,19 +2423,7 @@
       "tag": "Tài trợ",
       "tag": "Tài trợ",
       "visit": "Truy cập",
       "visit": "Truy cập",
       "yourBrand": "Thương hiệu của bạn ở đây",
       "yourBrand": "Thương hiệu của bạn ở đây",
-      "yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày.",
-      "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"
+      "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."
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/zh-CN.json

@@ -2423,19 +2423,7 @@
       "tag": "赞助",
       "tag": "赞助",
       "visit": "访问",
       "visit": "访问",
       "yourBrand": "您的品牌展示位",
       "yourBrand": "您的品牌展示位",
-      "yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。",
-      "placements": "可用展示位",
-      "placementDashboard": "概览横幅",
-      "placementDashboardDesc": "仪表盘顶部的高级位置,每次仅一家赞助商。",
-      "placementSidebar": "侧边栏卡片",
-      "placementSidebarDesc": "在面板所有页面显示,并在赞助商之间轮换。",
-      "placementLogin": "登录页",
-      "placementLoginDesc": "管理员每次登录时都能看到。",
-      "placementPage": "赞助商页面",
-      "placementPageDesc": "在此页面展示徽标、简介和链接。",
-      "available": "可预订",
-      "takenUntil": "已预订至 {date}",
-      "activeCount": "{count} 个进行中"
+      "yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。"
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {

+ 1 - 13
internal/web/translation/zh-TW.json

@@ -2423,19 +2423,7 @@
       "tag": "贊助",
       "tag": "贊助",
       "visit": "造訪",
       "visit": "造訪",
       "yourBrand": "您的品牌展示位",
       "yourBrand": "您的品牌展示位",
-      "yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。",
-      "placements": "可用展示位",
-      "placementDashboard": "總覽橫幅",
-      "placementDashboardDesc": "儀表板頂部的高級位置,每次僅一家贊助商。",
-      "placementSidebar": "側邊欄卡片",
-      "placementSidebarDesc": "在面板所有頁面顯示,並在贊助商之間輪換。",
-      "placementLogin": "登入頁",
-      "placementLoginDesc": "管理員每次登入時都能看到。",
-      "placementPage": "贊助商頁面",
-      "placementPageDesc": "在此頁面展示標誌、簡介與連結。",
-      "available": "可預訂",
-      "takenUntil": "已預訂至 {date}",
-      "activeCount": "{count} 個進行中"
+      "yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。"
     }
     }
   },
   },
   "tgbot": {
   "tgbot": {