Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
VITE_PANEL_DOMAIN=https://example.com
VITE_FALLBACK_LANGUAGE=en
VITE_PRIMARY_COLOR_LIGHT=oklch(0.48 0.11 250)
VITE_PRIMARY_COLOR_DARK=oklch(0.60 0.12 250)
VITE_BORDER_RADIUS=0.65rem
# Environment variables sample for subscription-template

# Set this to "true" to enable integration with the Sanaei 3x-ui package.
# When enabled, the app will try to use ThemeProvider and components from the 3x-ui package.
VITE_ENABLE_3X_UI=false

# Base URL for API (change to your panel's API)
VITE_API_BASE=https://panel.example.com/api

# Optional design tokens (overrides)
VITE_PRIMARY_COLOR_LIGHT=#2563eb
VITE_PRIMARY_COLOR_DARK=#1e40af
VITE_BORDER_RADIUS=8
105 changes: 21 additions & 84 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,94 +1,31 @@
# PasarGuard Subscription Template
# subscription-template — Sanaei integration

Responsive subscription page template for PasarGuard.
این شاخه (sanaei-integration) نسخه‌ای از subscription-template است که طوری تغییر یافته تا با پنل سنایی (MHSanaei/3x-ui) سازگار شود.

<p align="center">
<img src="https://raw.githubusercontent.com/PasarGuard/subscription-template/refs/heads/main/screenshots/en.png" alt="English UI" width="40%">
<img src="https://raw.githubusercontent.com/PasarGuard/subscription-template/refs/heads/main/screenshots/fa.png" alt="Persian UI" width="30%">
</p>
نکات مهم و نحوه استفاده

## Features
- پیش‌فرض: تغییرات غیرتهاجمی هستند و ظاهر/ساختار پروژه‌ی اصلی را تغییر نمی‌دهند.
- برای روشن کردن ادغام با 3x-ui، در فایل محلی `.env.local` مقدار زیر را قرار دهید:

- Languages: `en`, `fa`, `zh`, `ru`
- User can switch language in UI
- Responsive layout
- Dark mode
- QR code for connection links
- Copy links/configs in one click, with Base64 copy available only in the QR modal
- WireGuard links can be copied as native config or downloaded as `.conf`
- [Appearance customization](#appearance-customization)

## Compatibility

| Subscription Template | PasarGuard Panel |
| --- | --- |
| `v2` | `v3` |
| Other versions | `v2`, `v1` |

## Quick Start (Recommended)

Run installer script (choose your fallback language):

```sh
curl -fsSL https://raw.githubusercontent.com/PasarGuard/subscription-template/main/install.sh | sudo bash -s -- --lang fa
```

Supported values for `--lang`: `en`, `fa`, `zh`, `ru`
Supported values for `--version`: `latest` (default) or a release tag like `v2.0.0`
To install a specific release, add `--version <tag>`.

## Manual Install

1. Download template:

```sh
sudo mkdir -p /var/lib/pasarguard/templates/subscription
sudo wget -O /var/lib/pasarguard/templates/subscription/index.html \
https://github.com/PasarGuard/subscription-template/releases/latest/download/index.html
```

2. Configure PasarGuard in `/opt/pasarguard/.env`:

```dotenv
CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/"
SUBSCRIPTION_PAGE_TEMPLATE="subscription/index.html"
```

3. Restart:

```sh
pasarguard restart
```

## Build From Source

```sh
git clone https://github.com/PasarGuard/subscription-template.git
cd subscription-template
bun install
bun run build
```

Use the built file:

```sh
sudo cp dist/index.html /var/lib/pasarguard/templates/subscription/index.html
VITE_ENABLE_3X_UI=true
VITE_API_BASE=https://your-sanaei-panel.example.com
```

<a id="appearance-customization"></a>
- مراحل محلی تست:
1) git fetch origin
2) git checkout sanaei-integration
3) npm ci
4) npm run dev

## Appearance Customization

Set these in `.env` and build again:

```dotenv
VITE_PRIMARY_COLOR_LIGHT=oklch(0.48 0.11 250)
VITE_PRIMARY_COLOR_DARK=oklch(0.60 0.12 250)
VITE_BORDER_RADIUS=0.65rem
```
- برای ساخت production:
npm run build && npm run preview

## Other Languages
چه چیزهایی اضافه شد
- adapter/wrapper برای کامپوننت‌های پایه (Button, Card, Input, Badge, Modal, Toaster) در `src/components/ui/`.
- `src/components/theme-provider.tsx` به‌صورت شرطی از ThemeProvider پنل سنایی استفاده می‌کند اگر متغیر محیطی فعال باشد.
- `VITE_API_BASE` اضافه شده و hooks برای استفاده از آن بروز شدند.

- [فارسی (Persian)](README.fa.md)
- [Русский (Russian)](README.ru.md)
- [中文 (Chinese)](README.zh.md)
نکات و ریسک‌ها
- اگر پکیج `@MHSanaei/3x-ui` به صورت خصوصی باشد یا ساختار اکسپورت آن متفاوت باشد، lazy-import در wrapperها ممکن است fallback را نشان دهد. در این حالت، UI اصلی بدون تغییر کار خواهد کرد.
- من تست اولیه build را انجام داده و خطاهای آشکار را رفع می‌کنم؛ اما لطفاً خودتان نیز بررسی کنید و اگر نیاز به tweak بصری خاصی بود اعلام کنید.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"sonner": "^2.0.7",
"swr": "^2.4.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
"tailwindcss": "^4.2.2",
"@MHSanaei/3x-ui": "github:MHSanaei/3x-ui"
Comment on lines +36 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package.json relevant lines =="
if [ -f package.json ]; then
  cat -n package.json | sed -n '1,90p'
else
  fd -a '^package\.json$' .
fi

echo
echo "== imports/usages of ThemeProvider and `@MHSanaei/3x-ui` =="
rg -n "ThemeProvider|`@MHSanaei/3x-ui`|3x-ui|`@MHSanaei`" . --glob '!node_modules' --glob '!dist' --glob '!build' || true

echo
echo "== files containing "3x-ui-frontend" or similar frontend clues =="
rg -n "3x-ui-frontend|MHSanaei/3x-ui|frontend|package.json" . --glob '!node_modules' --glob '!dist' --glob '!build' | head -200

Repository: PasarGuard/subscription-template

Length of output: 6462


🌐 Web query:

github.com MHSanaei 3x-ui package.json 3x-ui-frontend

💡 Result:

In the MHSanaei/3x-ui repository, the directory named frontend (often referred to as 3x-ui-frontend in developer documentation) contains the source code for the project's modern React-based Single Page Application (SPA) web management panel [1][2][3]. The frontend directory includes a package.json file that defines the project's dependencies and build scripts [4][3]. As of July 2026, the frontend is built using a modern stack including: - Framework: React 19 [4][3] - UI Library: Ant Design 6 [4][3] - Build Tool: Vite 8 [4][3] - State Management: TanStack Query v5 [4] - Routing: React Router v7 [4] - Validation: Zod [4][5] - HTTP Client: Axios [4] The frontend code is organized to mirror the Go backend's structure and utilizes automated code generation to maintain type safety [1][6]. Specifically, tools in the repo generate TypeScript types and Zod schemas from Go structs, which are then placed in the frontend/src/generated/ directory to keep the UI synchronized with backend API changes [2][6]. During the build process, the React SPA is compiled into static assets and embedded into the main Go binary using Go's embed.FS feature, allowing the entire application to be served from a single binary [1][2].

Citations:


🌐 Web query:

MHSanaei 3x-ui root package.json 3x-ui-frontend

💡 Result:

In the MHSanaei/3x-ui repository, the frontend component is located in the dedicated frontend/ directory [1][2]. The file named package.json located in the root of the repository is for the Go backend project, while the frontend configuration file, named package.json (identifying the project as 3x-ui-frontend) [3][4], resides at frontend/package.json [1][5]. Key details regarding this architecture include: - Repository Structure: The repository follows a clear separation where the Go backend source code is primarily housed in the root and internal/ directories [1][6][7], and the React-based Single Page Application (SPA) is maintained in the frontend/ directory [2][5][8]. - Frontend package.json: The file located at frontend/package.json defines the project as 3x-ui-frontend [3][4]. It manages the dependencies and scripts for the React 19, Ant Design 6, and Vite 8 stack [9][10][8]. - Build Integration: The frontend is not deployed separately; rather, the npm run build command triggers the build process (including API schema generation from Go models) and outputs the assets into internal/web/dist/ [1][9][8]. These assets are then embedded directly into the final Go binary using the go:embed directive [1][7]. - Code Generation: The frontend package includes scripts such as gen:zod and gen:api [9][8]. These scripts utilize Go-based tools (specifically in tools/openapigen/) to parse backend Go models and generate type-safe TypeScript interfaces and Zod validation schemas, ensuring consistency between the backend API and frontend data structures [7][8].

Citations:


Point npm at the installable frontend package instead of the 3X-UI app repository.

@MHSanaei/3x-ui currently points to the repository root, whose frontend manifest is frontend/package.json with package name 3x-ui-frontend, not a root npm package exporting ThemeProvider, Toaster, Card, etc. The eager theme-provider.tsx import will fail package resolution on install/build. Use the scoped frontend package, a private installable copy, or a local workspace that exports these symbols.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` around lines 36 - 37, Update the `@MHSanaei/3x-ui` dependency in
package.json so it resolves to the installable frontend package defined by
frontend/package.json, rather than the repository root; use the scoped frontend
package, a private installable copy, or a local workspace that exports
ThemeProvider, Toaster, Card, and the other required symbols.

Source: MCP tools

},
"devDependencies": {
"@eslint/js": "^9.39.4",
Expand Down
13 changes: 12 additions & 1 deletion src/components/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes";

// Import Sanaei theme provider (installed from the 3x-ui repo). This import is safe
// because the dependency is added to package.json in the sanaei-integration branch.
// The provider will be used only when VITE_ENABLE_3X_UI is set to "true".
import { ThemeProvider as SanaeiThemeProvider } from "@MHSanaei/3x-ui";

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const enable3x = import.meta.env.VITE_ENABLE_3X_UI === "true";

if (enable3x) {
// @ts-ignore - accept any props for the external provider
return <SanaeiThemeProvider {...(props as any)}>{children}</SanaeiThemeProvider>;
Comment on lines +13 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)(package\.json|eslint.*\.(js|cjs|mjs|json)$|src/components/theme-provider\.tsx|.*sanaei.*|.*theme.*provider.*)' || true

echo
echo "== theme-provider =="
if [ -f src/components/theme-provider.tsx ]; then
  cat -n src/components/theme-provider.tsx
fi

echo
echo "== package and deps =="
if [ -f package.json ]; then
  cat package.json | jq '{dependencies, devDependencies, scripts}' // true
fi

echo
echo "== eslint snippets =="
for f in $(git ls-files | rg '(^|/)(eslint\.config\.(js|cjs|mjs)|\.eslintrc(\..*)?|package\.json)$' || true); do
  echo "--- $f"
  sed -n '1,220p' "$f"
done

echo
echo "== search suppressions/rules =="
rg -n "`@ts-ignore`|typescript-eslint|no-explicit-any|ban-ts-comment|typescript|next-themes|SanaeiThemeProvider" .

Repository: PasarGuard/subscription-template

Length of output: 1337


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== package.json =="
cat package.json

echo
echo "== eslint.config.js outlines and contents =="
wc -l eslint.config.js
sed -n '1,220p' eslint.config.js

echo
echo "== relevant source/config/search =="
rg -n -C 3 '`@ts-ignore`|typescript-eslint|no-explicit-any|ban-ts-comment|next-themes|SanaeiThemeProvider|ThemeProvider' .

Repository: PasarGuard/subscription-template

Length of output: 20857


Remove the @ts-ignore and any escape hatch.

eslint.config.js enables typescript-eslint recommended rules, so @ts-ignore/no-explicit-any will fail lint here. Replace the cast with the Sanaei provider’s actual prop type or explicitly map only the compatible next-themes props (attribute, defaultTheme, enableSystem, enableColorScheme) to avoid hiding prop incompatibilities.

🧰 Tools
🪛 ESLint

[error] 13-13: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free.

(@typescript-eslint/ban-ts-comment)


[error] 14-14: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/theme-provider.tsx` around lines 13 - 14, Update the wrapper
rendering SanaeiThemeProvider to remove the `@ts-ignore` and any cast, using its
actual prop type or explicitly forwarding only the compatible next-themes props
attribute, defaultTheme, enableSystem, and enableColorScheme while preserving
children.

Source: Linters/SAST tools

}

return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
Comment on lines +12 to 17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

rg -n -P 'from ["'\''"]next-themes["'\''"]|\buseTheme\s*\(' src

Repository: PasarGuard/subscription-template

Length of output: 650


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files containing next-themes consumers:\n'
git ls-files | rg 'theme-provider|theme-toggle|sonner|next-themes|package.json' || true

printf '\nRelevant file excerpts:\n'
for f in src/components/theme-provider.tsx src/components/theme-toggle.tsx src/components/ui/sonner.tsx; do
  if [ -f "$f" ]; then
    echo "--- $f"
    nl -ba "$f" | sed -n '1,80p'
  fi
done

printf '\nPackage dependency references:\n'
for f in package.json src/components/theme-provider.tsx; do
  if [ -f "$f" ]; then
    echo "--- $f"
    rg -n '"[^"]*next-themes[^"]*"|next-themes|packageManager|dependencies|devDependencies' "$f" || true
  fi
done

printf '\nProvider imports/usages in src:\n'
rg -n -P 'NextThemesProvider|SanaeiThemeProvider|theme-provider|ThemeProvider|useTheme' src

Repository: PasarGuard/subscription-template

Length of output: 422


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Relevant file excerpts:\n'
for f in src/components/theme-provider.tsx src/components/theme-toggle.tsx src/components/ui/sonner.tsx package.json; do
  if [ -f "$f" ]; then
    echo "--- $f"
    awk '{printf "%5d\t%s\n", NR, $0}' "$f" | sed -n '1,120p'
  fi
done

printf '\nProvider imports/usages in src:\n'
rg -n -P 'NextThemesProvider|SanaeiThemeProvider|theme-provider|ThemeProvider|useTheme' src

printf '\nPackage dependency references:\n'
rg -n -P '"[^"]*next-themes[^"]*"|\bnext-themes\b|packageManager|dependencies|devDependencies' package.json src/components/theme-provider.tsx || true

Repository: PasarGuard/subscription-template

Length of output: 7945


Bridge or migrate the next-themes context for 3x UI mode.

theme-toggle.tsx and src/components/ui/sonner.tsx both call useTheme() from next-themes, but when VITE_ENABLE_3X_UI is true this route only renders SanaeiThemeProvider. Either keep NextThemesProvider wrapped/bridged in that path, or migrate those consumers to the provider being used by 3x UI.

🧰 Tools
🪛 ESLint

[error] 13-13: Use "@ts-expect-error" instead of "@ts-ignore", as "@ts-ignore" will do nothing if the following line is error-free.

(@typescript-eslint/ban-ts-comment)


[error] 14-14: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/theme-provider.tsx` around lines 12 - 17, Update the enable3x
branch in the theme provider so next-themes consumers such as theme-toggle.tsx
and sonner.tsx still receive a valid NextThemesProvider context, or migrate
those consumers to SanaeiThemeProvider’s context while preserving their existing
behavior; keep the current provider selection for non-3x mode unchanged.

}

22 changes: 22 additions & 0 deletions src/components/ui/Badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { Suspense } from 'react';

const enable3x = import.meta.env.VITE_ENABLE_3X_UI === 'true';
let SanaeiBadge: React.LazyExoticComponent<any> | null = null;
if (enable3x) {
SanaeiBadge = React.lazy(() => import('@MHSanaei/3x-ui').then((m: any) => ({ default: m.Badge || (() => null) })));
}

export type BadgeProps = React.ComponentProps<'span'> & { variant?: string };

export default function Badge({ children, ...props }: BadgeProps) {
if (enable3x && SanaeiBadge) {
return (
<Suspense fallback={<span {...props}>{children}</span>}>
{/* @ts-ignore */}
<SanaeiBadge {...props}>{children}</SanaeiBadge>
</Suspense>
);
}

return <span {...props}>{children}</span>;
}
23 changes: 23 additions & 0 deletions src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { Suspense } from 'react';

const enable3x = import.meta.env.VITE_ENABLE_3X_UI === 'true';

let SanaeiButton: React.LazyExoticComponent<any> | null = null;
if (enable3x) {
SanaeiButton = React.lazy(() => import('@MHSanaei/3x-ui').then((m: any) => ({ default: m.Button || m.ButtonComponent || (() => null) })));
}

export type ButtonProps = React.ComponentProps<'button'> & { variant?: string };

export default function Button(props: ButtonProps) {
if (enable3x && SanaeiButton) {
return (
<Suspense fallback={<button {...props} />}>
{/* @ts-ignore */}
<SanaeiButton {...props} />
</Suspense>
);
}

return <button {...props} />;
}
22 changes: 22 additions & 0 deletions src/components/ui/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { Suspense } from 'react';

const enable3x = import.meta.env.VITE_ENABLE_3X_UI === 'true';
let SanaeiCard: React.LazyExoticComponent<any> | null = null;
if (enable3x) {
SanaeiCard = React.lazy(() => import('@MHSanaei/3x-ui').then((m: any) => ({ default: m.Card || m.Panel || (() => null) })));
}

export type CardProps = React.ComponentProps<'div'> & { title?: React.ReactNode };

export default function Card({ children, ...props }: CardProps) {
if (enable3x && SanaeiCard) {
return (
<Suspense fallback={<div {...props}>{children}</div>}>
{/* @ts-ignore */}
<SanaeiCard {...props}>{children}</SanaeiCard>
</Suspense>
);
}

return <div {...props}>{children}</div>;
}
22 changes: 22 additions & 0 deletions src/components/ui/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { Suspense } from 'react';

const enable3x = import.meta.env.VITE_ENABLE_3X_UI === 'true';
let SanaeiInput: React.LazyExoticComponent<any> | null = null;
if (enable3x) {
SanaeiInput = React.lazy(() => import('@MHSanaei/3x-ui').then((m: any) => ({ default: m.Input || m.TextField || (() => null) })));
}

export type InputProps = React.ComponentProps<'input'> & { label?: string };

export default function Input(props: InputProps) {
if (enable3x && SanaeiInput) {
return (
<Suspense fallback={<input {...props} />}>
{/* @ts-ignore */}
<SanaeiInput {...props} />
</Suspense>
);
}

return <input {...props} />;
}
32 changes: 32 additions & 0 deletions src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { Suspense } from 'react';

const enable3x = import.meta.env.VITE_ENABLE_3X_UI === 'true';
let SanaeiModal: React.LazyExoticComponent<any> | null = null;
if (enable3x) {
SanaeiModal = React.lazy(() => import('@MHSanaei/3x-ui').then((m: any) => ({ default: m.Modal || m.Dialog || (() => null) })));
}

export type ModalProps = React.ComponentProps<'div'> & { open?: boolean; onClose?: () => void };

export default function Modal({ children, ...props }: ModalProps) {
if (enable3x && SanaeiModal) {
return (
<Suspense fallback={<div {...props}>{children}</div>}>
{/* @ts-ignore */}
<SanaeiModal {...props}>{children}</SanaeiModal>
</Suspense>
);
}

// simple fallback modal (non-portal)
if (props.open) {
return (
<div role="dialog" aria-modal="true" {...props} className={`fixed inset-0 z-50 flex items-center justify-center ${props.className || ''}`}>
<div className="bg-black/50 absolute inset-0" onClick={props.onClose}></div>
<div className={`relative z-10`}>{children}</div>
</div>
);
}

return null;
}
30 changes: 30 additions & 0 deletions src/components/ui/Toaster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React, { Suspense } from 'react';

const enable3x = import.meta.env.VITE_ENABLE_3X_UI === 'true';
let SanaeiToaster: React.LazyExoticComponent<any> | null = null;
if (enable3x) {
SanaeiToaster = React.lazy(() => import('@MHSanaei/3x-ui').then((m: any) => ({ default: m.Toaster || m.Toast || (() => null) })));
}

export type ToasterProps = any;

export default function Toaster(props: ToasterProps) {
if (enable3x && SanaeiToaster) {
return (
<Suspense fallback={null}>
{/* @ts-ignore */}
<SanaeiToaster {...props} />
</Suspense>
);
}

// Fallback: keep using sonner-based Toaster if present in the codebase
// The existing project exposes a Toaster at '@/components/ui/sonner'
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Toaster: SonnerToaster } = require('@/components/ui/sonner');
return <SonnerToaster {...props} />;
} catch (e) {
return null;
}
}
Loading