done i18n

This commit is contained in:
2026-03-06 18:46:21 +00:00
parent 3c24da4af8
commit edc1a33547
44 changed files with 2289 additions and 2390 deletions

219
CLAUDE.md
View File

@@ -2,197 +2,82 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project overview
Holistream is a Vue 3 streaming application with Server-Side Rendering (SSR) deployed on Cloudflare Workers. It provides video upload, management, and streaming capabilities. `stream-ui` is a Vue 3 SSR frontend deployed on Cloudflare Workers. It uses Hono as the Worker server layer and a custom Vite SSR setup rather than Nuxt.
## Technology Stack ## Common commands
- **Framework**: Vue 3 with JSX/TSX support Run all commands from `stream-ui/`.
- **Router**: Vue Router 5 with SSR-aware history
- **Server**: Hono framework on Cloudflare Workers
- **Build Tool**: Vite 7 with custom SSR plugin
- **Styling**: UnoCSS (Tailwind-like utility-first CSS)
- **UI Components**: PrimeVue 4 with Aura theme
- **State Management**: Pinia + Pinia Colada for server state
- **HTTP Client**: Auto-generated from OpenAPI spec via swagger-typescript-api
- **Package Manager**: Bun
## Common Commands
```bash ```bash
# Development server with hot reload # Install dependencies
bun dev bun install
# Production build (client + worker) # Start local dev server
bun run dev
# Build client + worker bundles
bun run build bun run build
# Preview production build locally # Preview production build locally
bun preview bun run preview
# Deploy to Cloudflare Workers # Deploy to Cloudflare Workers
bun run deploy bun run deploy
# Generate TypeScript types from Wrangler config # Regenerate Cloudflare binding types from Wrangler config
bun run cf-typegen bun run cf-typegen
# View Cloudflare Worker logs # Tail Cloudflare Worker logs
bun run tail bun run tail
``` ```
**Note**: The project uses Bun as the package manager. If using npm/yarn, replace `bun` with `npm run` or `yarn`. Notes:
- This project uses Bun (`bun.lock` is present).
- There is currently no configured `test` script.
- There is currently no configured `lint` script.
## Architecture ## Architecture
### SSR Architecture ### SSR entrypoints
- `src/index.tsx`: Hono Worker entry; registers middleware, proxy routes, merge/display/manifest routes, then SSR routes
- `src/main.ts`: shared app factory for SSR and client hydration
- `src/client.ts`: client-side hydration entry
- `ssrPlugin.ts`: custom Vite SSR plugin that builds the client first, injects the Vite manifest, and swaps environment-specific modules
The app uses a custom SSR setup (`ssrPlugin.ts`) that: ### Routing and app structure
- Builds the client bundle FIRST, then the Worker bundle - Routes live in `src/routes/index.ts`.
- Injects the Vite manifest into the server build for asset rendering - Routing is SSR-aware: `createMemoryHistory()` on the server and `createWebHistory()` in the browser.
- Uses environment-based module resolution for `httpClientAdapter` and `liteMqtt` - The app is split into:
- public pages
- auth pages
- protected dashboard/settings pages
- Current protected areas include `videos`, `notification`, and `settings/*` routes.
Entry points: ### State and hydration
- **Server**: `src/index.tsx` - Hono app that renders Vue SSR stream - Pinia is used for app state.
- **Client**: `src/client.ts` - Hydrates the SSR-rendered app - `@pinia/colada` is used for server-state/query hydration.
- SSR serializes Pinia state into `$p` and query cache into `$colada`; `src/client.ts` restores both during hydration.
- `src/stores/auth.ts` owns session state and route guards depend on `auth.user`.
### Module Aliases ### API integration
- `src/api/client.ts` is generated by `swagger-typescript-api`; do not hand-edit generated sections.
- API access should go through the generated client and `@httpClientAdapter`, not raw `fetch`.
- `src/api/httpClientAdapter.server.ts` handles SSR-side API calls by forwarding request headers/cookies and proxying frontend `/r/*` requests to `https://api.pipic.fun`.
- `src/api/httpClientAdapter.client.ts` is the browser-side adapter.
- `@/``src/` ### Notable flows
- `@httpClientAdapter``src/api/httpClientAdapter.server.ts` (SSR) or `.client.ts` (browser) - `src/stores/auth.ts` initializes the logged-in user from `/me` and opens an MQTT connection after login.
- `@liteMqtt``src/lib/liteMqtt.server.ts` (SSR) or `.ts` (browser) - `src/composables/useUploadQueue.ts` implements the custom upload queue:
- 90MB chunks
- max 3 parallel uploads
- max 3 retries
- max 5 queued items
- Styling uses UnoCSS (`uno.config.ts`).
### State Management Pattern ## Important notes
Uses **Pinia Colada** for server state with SSR hydration: - Prefer the actual current code over older documentation when they conflict.
- Queries are fetched server-side and serialized to `window.__APP_DATA__` - The previous version of this file contained stale route and dependency details; verify against `src/routes/index.ts` and `package.json` before assuming old pages or libraries still exist.
- Client hydrates the query cache on startup via `hydrateQueryCache()` - Any frontend change that affects API contracts should be checked against the backend repo (`../stream.api`) as well.
- Pinia state is similarly serialized and restored via `PiniaSharedState` plugin
### API Client Architecture
The API client (`src/api/client.ts`) is auto-generated from OpenAPI spec:
- Uses `customFetch` adapter that differs between client/server
- Server adapter (`httpClientAdapter.server.ts`): Forwards cookies via `hono/context-storage`, merges headers, calls `https://api.pipic.fun`
- Client adapter (`httpClientAdapter.client.ts`): Standard fetch with `credentials: "include"`
- API proxy route: `/r/*` paths proxy to `https://api.pipic.fun` via `apiProxyMiddleware`
- Base API URL constant: `baseAPIURL = "https://api.pipic.fun"`
### Routing Structure
Routes are defined in `src/routes/index.ts` with three main layouts:
1. **Public** (`/`): Landing page, terms, privacy
2. **Auth** (`/login`, `/sign-up`, `/forgot`): Authentication pages (redirects if logged in)
3. **Dashboard**: Protected routes requiring auth
- `/overview` - Main dashboard
- `/upload` - Video upload
- `/video` - Video list
- `/video/:id` - Video detail/edit
- `/payments-and-plans` - Billing
- `/notification`, `/profile` - User settings
Route meta supports `@unhead/vue` for SEO: `meta: { head: { title, meta: [...] } }`
### Styling System (UnoCSS)
Configuration in `uno.config.ts`:
- **Presets**: Wind4 (Tailwind), Typography, Attributify, Bootstrap buttons
- **Custom colors**: `primary` (#14a74b), `accent`, `secondary` (#fd7906), `success`, `info`, `warning`, `danger`
- **Shortcuts**: `press-animated` for button press effects
- **Transformers**: `transformerCompileClass` (prefix: `_`), `transformerVariantGroup`
Use `cn()` from `src/lib/utils.ts` for conditional class merging (clsx + tailwind-merge).
### Component Auto-Import
Components in `src/components/` are auto-imported via `unplugin-vue-components`:
- PrimeVue components resolved via `PrimeVueResolver`
- Vue/Pinia/Vue Router APIs auto-imported via `unplugin-auto-import`
### Auth Flow
- `useAuthStore` manages auth state with cookie-based sessions
- `init()` called on every request to fetch current user via `/me` endpoint
- `beforeEach` router guard redirects unauthenticated users from protected routes
- MQTT client connects on user login for real-time notifications
### File Upload Architecture
Upload queue (`src/composables/useUploadQueue.ts`):
- Supports both local files and remote URLs
- Presigned POST URLs fetched from API
- Parallel chunk upload for large files
- Progress tracking with speed calculation
- **Chunk configuration**: 90MB chunks, max 3 parallel uploads, max 3 retries
- **Upload limits**: Max 5 items in queue
- Uses `tmpfiles.org` API for chunk uploads, `/merge` endpoint for finalizing
- Cancel support via XHR abort tracking
### Type Safety
- TypeScript strict mode enabled
- `CloudflareBindings` interface for environment variables (generated via `cf-typegen`)
- API types auto-generated from backend OpenAPI spec
### Environment Variables
Cloudflare Worker bindings (configured in `wrangler.jsonc`):
- No explicit secrets in code - use Wrangler secrets management
- `compatibility_date`: "2025-08-03"
- `compatibility_flags`: ["nodejs_compat"]
## Important File Locations
| Purpose | Path |
|---------|------|
| Server entry | `src/index.tsx` |
| Client entry | `src/client.ts` |
| App factory | `src/main.ts` |
| Router config | `src/routes/index.ts` |
| API client | `src/api/client.ts` |
| Auth store | `src/stores/auth.ts` |
| SSR plugin | `ssrPlugin.ts` |
| UnoCSS config | `uno.config.ts` |
| Wrangler config | `wrangler.jsonc` |
| Vite config | `vite.config.ts` |
## Server Structure
Middleware and routes are organized in `src/server/`:
**Middlewares** (`src/server/middlewares/`):
- `setup.ts` - Global middleware: `contextStorage`, CORS, mobile detection via `is-mobile`
- `apiProxy.ts` - Proxies `/r/*` requests to external API
**Routes** (`src/server/routes/`):
- `ssr.ts` - Handles SSR rendering and state serialization
- `display.ts`, `merge.ts`, `manifest.ts`, `wellKnown.ts` - API endpoints
## Development Notes
- Always use `customFetch` from `@httpClientAdapter` for API calls, never raw fetch
- The `honoContext` is provided to Vue app for accessing request context in components
- MQTT client in `src/lib/liteMqtt.ts` (using `TinyMqttClient`) handles real-time notifications
- Icons are custom Vue components in `src/components/icons/`
- Upload indicator is a global component showing queue status
- Root component uses error boundary wrapper: `withErrorBoundary(RouterView)` in `src/main.ts`
- **Testing & Linting**: There are currently no automated test suites (like Vitest) or linting tools (like ESLint/Prettier) configured.
## Code Organization
### Component Structure
- Keep view components small and focused - extract logical sections into child components
- Page views should compose child components, not contain all logic inline
- Example: `src/routes/settings/Settings.vue` uses child components in `src/routes/settings/components/`
- Components that exceed ~200 lines should be considered for refactoring
- Use `components/` subfolder pattern for page-specific components: `src/routes/{feature}/components/`
### Icons
- **Use custom SVG icon components** from `src/components/icons/` for UI icons (e.g., `Home`, `Video`, `Bell`, `SettingsIcon`)
- Custom icons are Vue components with `filled` prop for active/filled state
- PrimeIcons (`pi pi-*` class) should **only** be used for:
- Button icons in PrimeVue components (e.g., `icon="pi pi-check"`)
- Dialog/action icons where no custom SVG exists
- **Do NOT use** `<i class="pi pi-*">` for navigation icons, action buttons, or UI elements that have custom SVG equivalents
- When adding new icons, create SVG components in `src/components/icons/` following the existing pattern (support `filled` prop)

BIN
golang.tar.gz Normal file

Binary file not shown.

View File

@@ -554,7 +554,7 @@
}, },
"overview": { "overview": {
"welcome": { "welcome": {
"title": "Welcome back, {name}! 👋", "title": "Hello, {{name}}",
"subtitle": "Here's what's happening with your content today." "subtitle": "Here's what's happening with your content today."
}, },
"stats": { "stats": {

View File

@@ -554,7 +554,7 @@
}, },
"overview": { "overview": {
"welcome": { "welcome": {
"title": "Chào mừng trở lại, {name}! 👋", "title": "Xin chào, {{name}}",
"subtitle": "Đây là tình hình nội dung của bạn hôm nay." "subtitle": "Đây là tình hình nội dung của bạn hôm nay."
}, },
"stats": { "stats": {
@@ -640,7 +640,7 @@
}, },
"filters": { "filters": {
"searchPlaceholder": "Tìm kiếm video...", "searchPlaceholder": "Tìm kiếm video...",
"rangeOfTotal": "{first}{last} / {total}", "rangeOfTotal": "{{first}}{{last}} / {{total}}",
"previousPageAria": "Trang trước", "previousPageAria": "Trang trước",
"nextPageAria": "Trang sau", "nextPageAria": "Trang sau",
"allStatus": "Tất cả trạng thái", "allStatus": "Tất cả trạng thái",
@@ -929,7 +929,7 @@
"description": "Nội dung được phân phối từ hơn 200 PoP trên toàn thế giới. Tự động chọn vùng để có độ trễ thấp nhất cho mọi người xem." "description": "Nội dung được phân phối từ hơn 200 PoP trên toàn thế giới. Tự động chọn vùng để có độ trễ thấp nhất cho mọi người xem."
}, },
"live": { "live": {
"title": "API livestream", "title": "Live Streaming API",
"description": "Mở rộng tới hàng triệu người xem đồng thời với độ trễ cực thấp. Hỗ trợ RTMP ingest và HLS playback sẵn có.", "description": "Mở rộng tới hàng triệu người xem đồng thời với độ trễ cực thấp. Hỗ trợ RTMP ingest và HLS playback sẵn có.",
"status": "Trạng thái trực tiếp", "status": "Trạng thái trực tiếp",
"onAir": "Đang phát", "onAir": "Đang phát",

View File

@@ -1,31 +1,125 @@
import { tryGetContext } from "hono/context-storage"; import { tryGetContext } from 'hono/context-storage';
export const baseAPIURL = "https://api.pipic.fun";
export const customFetch = (url: string, options: RequestInit) => { // export const baseAPIURL = 'https://api.pipic.fun';
options.credentials = "include"; export const baseAPIURL = 'http://localhost:8080';
type RequestOptions = RequestInit | { raw: Request };
const isRequest = (input: URL | RequestInfo): input is Request =>
typeof Request !== 'undefined' && input instanceof Request;
const isRequestLikeOptions = (options: RequestOptions): options is { raw: Request } =>
typeof options === 'object' && options !== null && 'raw' in options && options.raw instanceof Request;
const resolveInputUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
if (input instanceof URL) return new URL(input.toString());
if (isRequest(input)) return new URL(input.url);
const baseUrl = new URL(currentRequestUrl);
baseUrl.pathname = '/';
baseUrl.search = '';
baseUrl.hash = '';
return new URL(input, baseUrl);
};
const resolveApiUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
const inputUrl = resolveInputUrl(input, currentRequestUrl);
const apiUrl = new URL(baseAPIURL);
apiUrl.pathname = inputUrl.pathname.replace(/^\/?r(?=\/|$)/, '') || '/';
apiUrl.search = inputUrl.search;
apiUrl.hash = inputUrl.hash;
return apiUrl;
};
const getOptionHeaders = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.headers : options.headers;
const getOptionMethod = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.method : options.method;
const getOptionBody = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.body : options.body;
const getOptionSignal = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.signal : options.signal;
const getOptionCredentials = (options: RequestOptions) =>
isRequestLikeOptions(options) ? undefined : options.credentials;
const mergeHeaders = (input: URL | RequestInfo, options: RequestOptions) => {
const c = tryGetContext<any>();
const mergedHeaders = new Headers(c?.req.raw.headers ?? undefined);
const inputHeaders = isRequest(input) ? input.headers : undefined;
const optionHeaders = getOptionHeaders(options);
new Headers(inputHeaders).forEach((value, key) => {
mergedHeaders.set(key, value);
});
new Headers(optionHeaders).forEach((value, key) => {
mergedHeaders.set(key, value);
});
mergedHeaders.delete('host');
mergedHeaders.delete('connection');
mergedHeaders.delete('content-length');
mergedHeaders.delete('transfer-encoding');
return mergedHeaders;
};
const resolveMethod = (input: URL | RequestInfo, options: RequestOptions) => {
const method = getOptionMethod(options);
if (method) return method;
if (isRequest(input)) return input.method;
return 'GET';
};
const resolveBody = (input: URL | RequestInfo, options: RequestOptions, method: string) => {
if (method === 'GET' || method === 'HEAD') return undefined;
const body = getOptionBody(options);
if (typeof body !== 'undefined') return body;
if (isRequest(input)) return input.body;
return undefined;
};
export const customFetch = (input: URL | RequestInfo, options: RequestOptions = {}) => {
const c = tryGetContext<any>(); const c = tryGetContext<any>();
if (!c) { if (!c) {
throw new Error("Hono context not found in SSR"); throw new Error('Hono context not found in SSR');
} }
// Merge headers properly - keep original options.headers and add request headers
const reqHeaders = new Headers(c.req.header());
// Remove headers that shouldn't be forwarded
reqHeaders.delete("host");
reqHeaders.delete("connection");
const mergedHeaders: Record<string, string> = {}; const apiUrl = resolveApiUrl(input, c.req.url);
reqHeaders.forEach((value, key) => { const method = resolveMethod(input, options);
mergedHeaders[key] = value; const body = resolveBody(input, options, method.toUpperCase());
}); const requestOptions: RequestInit & { duplex?: 'half' } = {
options.headers = { ...(isRequestLikeOptions(options) ? {} : options),
...mergedHeaders, method,
...(options.headers as Record<string, string>), headers: mergeHeaders(input, options),
body,
credentials: getOptionCredentials(options) ?? 'include',
signal: getOptionSignal(options) ?? (isRequest(input) ? input.signal : undefined),
}; };
const apiUrl = [baseAPIURL, url.replace(/^r/, "")].join(""); if (body) {
return fetch(apiUrl, options).then(async (res) => { requestOptions.duplex = 'half';
res.headers.getSetCookie()?.forEach((cookie) => { }
c.header("Set-Cookie", cookie);
}); return fetch(apiUrl, requestOptions).then((response) => {
return res; const setCookies = typeof response.headers.getSetCookie === 'function'
? response.headers.getSetCookie()
: response.headers.get('set-cookie')
? [response.headers.get('set-cookie')!]
: [];
for (const cookie of setCookies) {
c.header('Set-Cookie', cookie, { append: true });
}
return response;
}); });
}; };

View File

@@ -6,11 +6,9 @@ import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import XIcon from '@/components/icons/XIcon.vue'; import XIcon from '@/components/icons/XIcon.vue';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { onBeforeUnmount, watchEffect } from 'vue'; import { onBeforeUnmount, watchEffect } from 'vue';
import { useTranslation } from 'i18next-vue';
import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast'; import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast';
const { toasts, remove } = useAppToast(); const { toasts, remove } = useAppToast();
const { t } = useTranslation();
const timers = new Map<string, ReturnType<typeof setTimeout>>(); const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -93,7 +91,7 @@ onBeforeUnmount(() => {
type="button" type="button"
class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all" class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all"
@click="dismiss(t.id)" @click="dismiss(t.id)"
:aria-label="t('toast.dismissAria')" :aria-label="$t('toast.dismissAria')"
> >
<XIcon class="w-4 h-4" /> <XIcon class="w-4 h-4" />
</button> </button>

View File

@@ -30,10 +30,9 @@ const state = reactive<AppConfirmState>({
}); });
const requireConfirm = (options: AppConfirmOptions) => { const requireConfirm = (options: AppConfirmOptions) => {
const i18n = getActiveI18n(); const defaultHeader = 'Confirm';
const defaultHeader = i18n?.t('confirm.defaultHeader') ?? 'Confirm'; const defaultAccept = 'OK';
const defaultAccept = i18n?.t('confirm.defaultAccept') ?? 'OK'; const defaultReject = 'Cancel';
const defaultReject = i18n?.t('confirm.defaultReject') ?? 'Cancel';
state.visible = true; state.visible = true;
state.loading = false; state.loading = false;

View File

@@ -40,8 +40,7 @@ const abortItem = (id: string) => {
}; };
export function useUploadQueue() { export function useUploadQueue() {
const t = (key: string, params?: Record<string, unknown>) => const t = (key: string, params?: Record<string, unknown>) => key;
getActiveI18n()?.t(key, params) ?? key;
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length)); const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
@@ -330,7 +329,7 @@ export function useUploadQueue() {
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2)); const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return `${new Intl.NumberFormat(getActiveI18n()?.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US').format(value)} ${sizes[i]}`; return `${value} ${sizes[i]}`;
}; };
const totalSize = computed(() => { const totalSize = computed(() => {

View File

@@ -1,6 +1,5 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { serveStatic } from "@hono/node-server/serve-static";
import { apiProxyMiddleware } from './server/middlewares/apiProxy'; import { apiProxyMiddleware } from './server/middlewares/apiProxy';
import { setupMiddlewares } from './server/middlewares/setup'; import { setupMiddlewares } from './server/middlewares/setup';
import { registerDisplayRoutes } from './server/routes/display'; import { registerDisplayRoutes } from './server/routes/display';
@@ -16,7 +15,6 @@ setupMiddlewares(app);
// API proxy middleware (handles /r/*) // API proxy middleware (handles /r/*)
app.use(apiProxyMiddleware); app.use(apiProxyMiddleware);
app.use(serveStatic({ root: './public' }))
// Routes // Routes
registerWellKnownRoutes(app); registerWellKnownRoutes(app);
registerMergeRoutes(app); registerMergeRoutes(app);

View File

@@ -49,13 +49,6 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
}); });
} }
// const getRuntimeLocaleTag = () => {
// const locale = getActiveI18n()?.resolvedLanguage;
// return locale === 'vi' ? 'vi-VN' : 'en-US';
// };
export const formatBytes = (bytes?: number) => { export const formatBytes = (bytes?: number) => {
if (!bytes) return '0 B'; if (!bytes) return '0 B';
const k = 1024; const k = 1024;
@@ -80,7 +73,10 @@ export const formatDuration = (seconds?: number) => {
export const formatDate = (dateString: string = "", dateOnly: boolean = false) => { export const formatDate = (dateString: string = "", dateOnly: boolean = false) => {
if (!dateString) return ''; if (!dateString) return '';
return new Date(dateString).toLocaleDateString("en-US", { const locale = typeof document !== 'undefined'
? document.documentElement.lang === 'vi' ? 'vi-VN' : 'en-US'
: 'en-US';
return new Date(dateString).toLocaleDateString(locale, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View File

@@ -7,7 +7,6 @@ import { RouterView } from 'vue-router';
import I18NextVue from 'i18next-vue'; import I18NextVue from 'i18next-vue';
import { withErrorBoundary } from './lib/hoc/withErrorBoundary'; import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
import createI18nInstance from './lib/translation'; import createI18nInstance from './lib/translation';
import createAppRouter from './routes'; import createAppRouter from './routes';

View File

@@ -31,13 +31,7 @@
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p> <p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-end">
<div class="flex items-center gap-2">
<input id="remember-me" v-model="form.rememberMe" type="checkbox"
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
:disabled="auth.loading" />
<label for="remember-me" class="text-sm text-gray-900">{{ t('auth.login.signIn') }}</label>
</div>
<div class="text-sm"> <div class="text-sm">
<router-link to="/forgot" <router-link to="/forgot"
class="text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.login.forgotPassword') }}</router-link> class="text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.login.forgotPassword') }}</router-link>

View File

@@ -1,183 +1,21 @@
<template> <script setup lang="ts">
<section class=":m: relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden min-h-svh flex">
<div
class=":m: absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-primary-light/40 rounded-full blur-3xl -z-10 mix-blend-multiply animate-pulse duration-1000">
</div>
<div
class=":m: absolute bottom-0 left-0 translate-y-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply">
</div>
<div class="max-w-7xl m-auto px-4 sm:px-6 lg:px-8 text-center">
<h1
class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1] animate-backwards">
{{ t('home.hero.titleLine1') }} <br>
<span class="text-gradient">{{ t('home.hero.titleLine2') }}</span>
</h1>
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed animate-backwards delay-50">
{{ t('home.hero.subtitle') }}
</p>
<div class="flex flex-col sm:flex-row justify-center gap-4">
<RouterLink to="/get-started" class="flex btn btn-success !rounded-xl !p-4 press-animated">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580">
<path d="M56 284v-560L560 4 56 284z" fill="#fff" />
</svg>&nbsp;
{{ t('home.hero.getStarted') }}
</RouterLink>
<RouterLink to="/docs" class="flex btn btn-outline-primary !rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" width="28" viewBox="0 0 596 468">
<path
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
fill="#14a74b" />
<path
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s24-11 24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
fill="#fff" />
</svg>&nbsp;
{{ t('home.hero.uploadVideo') }}
</RouterLink>
</div>
</div>
</section>
<section id="features" class="py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-16 md:text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-slate-900 mb-4">{{ t('home.features.heading') }}</h2>
<p class="text-lg text-slate-500">{{ t('home.features.subtitle') }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
class=":m: md:col-span-2 bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-primary/60 transition-all group overflow-hidden relative">
<div class="relative z-10">
<div
class="w-12 h-12 bg-white rounded-xl flex items-center justify-center mb-6 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="532" viewBox="-8 -258 529 532">
<path
d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z"
fill="#059669" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">{{ t('home.features.global.title') }}</h3>
<p class="text-slate-500 max-w-md">{{ t('home.features.global.description') }}</p>
</div>
<div class="absolute right-0 bottom-0 opacity-10 translate-x-1/4 translate-y-1/4">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532">
<path
d="M464 8c0-19-3-38-8-56l-27-5c-8-2-15 2-19 9-6 11-19 17-31 13l-14-5c-8-2-17 0-22 5-4 4-4 10 0 14l33 33c5 5 8 12 8 19 0 12-8 23-20 26l-6 1c-3 1-6 5-6 9v12c0 13-4 27-13 38l-25 34c-6 8-16 13-26 13-18 0-32-14-32-32V88c0-9-7-16-16-16h-32c-26 0-48-22-48-48V-4c0-13 6-24 16-32l39-30c6-4 13-6 20-6 3 0 7 1 10 2l32 10c7 3 15 3 22 1l36-9c10-2 17-11 17-22 0-8-5-16-13-20l-29-15c-3-2-8-1-11 2l-4 4c-4 4-11 7-17 7-4 0-8-1-11-3l-15-7c-7-4-15-2-20 4l-13 17c-6 7-16 8-22 1-3-2-5-6-5-10v-41c0-6-1-11-4-16l-10-18C102-154 48-79 48 8c0 115 93 208 208 208S464 123 464 8zM0 8c0-141 115-256 256-256S512-133 512 8 397 264 256 264 0 149 0 8z"
fill="#1e3050" />
</svg>
</div>
</div>
<div class=":m: md:row-span-2 bg-slate-900 rounded-2xl p-8 text-white relative overflow-hidden group">
<div class=":m: absolute inset-0 bg-gradient-to-b from-slate-800/50 to-transparent"></div>
<div class="relative z-10">
<div
class=":m: w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center mb-6 backdrop-blur-sm border border-white/10">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -146 468 384">
<path
d="M392-136c-31 0-56 25-56 56v280c0 16 13 28 28 28h28c31 0 56-25 56-56V-80c0-31-25-56-56-56zM168 4c0-31 25-56 56-56h28c16 0 28 13 28 28v224c0 16-12 28-28 28h-56c-15 0-28-12-28-28V4zM0 88c0-31 25-56 56-56h28c16 0 28 13 28 28v140c0 16-12 28-28 28H56c-31 0-56-25-56-56V88z"
fill="#fff" />
</svg>
</div>
<h3 class="text-xl font-bold mb-2">{{ t('home.features.live.title') }}</h3>
<p class="text-slate-400 text-sm leading-relaxed mb-8">{{ t('home.features.live.description') }}</p>
<div
class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
<div class="flex justify-between items-center mb-3 border-b border-white/5 pb-2">
<span class="text-slate-500">{{ t('home.features.live.status') }}</span>
<span
class=":m: flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span
class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> {{ t('home.features.live.onAir') }}</span>
</div>
<div class="space-y-1">
<div class="flex justify-between"><span class="text-slate-400">{{ t('home.features.live.bitrate') }}</span> <span
class="text-white">{{ t('home.features.live.bitrateValue') }}</span></div>
<div class="flex justify-between"><span class="text-slate-400">{{ t('home.features.live.fps') }}</span> <span
class="text-white">{{ t('home.features.live.fpsValue') }}</span></div>
<div class="flex justify-between"><span class="text-slate-400">{{ t('home.features.live.latency') }}</span> <span
class="text-brand-400">{{ t('home.features.live.latencyValue') }}</span></div>
</div>
</div>
</div>
</div>
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-purple-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 570 570">
<path
d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z"
fill="#a6acb9" />
<path
d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z"
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">{{ t('home.features.encoding.title') }}</h3>
<p class="text-slate-500 text-sm">{{ t('home.features.encoding.description') }}</p>
</div>
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-orange-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -226 532 468">
<path
d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z"
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">{{ t('home.features.analytics.title') }}</h3>
<p class="text-slate-500 text-sm">{{ t('home.features.analytics.description') }}</p>
</div>
</div>
</div>
</section>
<section id="pricing" class="py-24 border-t border-slate-100 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-slate-900 mb-4">{{ pricing.title }}</h2>
<p class="text-slate-500">{{ pricing.subtitle }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
<div v-for="pack in pricing.packs" :key="pack.name"
:class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == t('home.pricing.pro.tag') ? 'border-primary/80 border-2' : 'border-slate-200 border')"
:style="{ background: pack.bg }">
<div v-if="pack.tag"
class=":m: absolute top-0 right-0 bg-primary/80 text-white text-xs font-bold px-3 py-1 rounded-bl-lg uppercase">
{{ pack.tag }}</div>
<div>
<h3 class="font-semibold text-slate-900 text-xl mb-2">{{ pack.name }}</h3>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-bold text-slate-900">{{ pack.price }}</span>
<span class="text-slate-500">{{ t('home.pricing.perMonth') }}</span>
</div>
</div>
<ul class="space-y-3 mb-8 text-sm text-slate-600">
<li v-for="value in pack.features" :key="value" class="flex items-center gap-3"><Check-Icon
class="fas fa-check text-brand-500" /> {{ value }}</li>
</ul>
<router-link to="/sign-up"
:class="cn('btn flex justify-center w-full !py-2.5', pack.tag == t('home.pricing.pro.tag') ? 'btn-primary' : 'btn-outline-primary')">{{
pack.buttonText }}</router-link>
</div>
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import { cn } from '@/lib/utils';
import { useTranslation } from 'i18next-vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useTranslation } from 'i18next-vue';
const { t } = useTranslation();
const signalItems = computed(() => [
{ label: t('home.features.live.bitrate'), value: t('home.features.live.bitrateValue') },
{ label: t('home.features.live.fps'), value: t('home.features.live.fpsValue') },
{ label: t('home.features.live.latency'), value: t('home.features.live.latencyValue') },
]);
const featurePills = computed(() => [
t('home.features.global.title'),
t('home.features.encoding.title'),
t('home.features.analytics.title'),
]);
const { t, i18next } = useTranslation();
console.log('Current locale:', i18next);
const getFeatureList = (key: string): string[] => { const getFeatureList = (key: string): string[] => {
const localized = t(key, { returnObjects: true }); const localized = t(key, { returnObjects: true });
return Array.isArray(localized) ? localized.map((item) => String(item)) : []; return Array.isArray(localized) ? localized.map((item) => String(item)) : [];
@@ -193,7 +31,6 @@ const pricing = computed(() => ({
features: getFeatureList('home.pricing.hobby.features'), features: getFeatureList('home.pricing.hobby.features'),
buttonText: t('home.pricing.hobby.button'), buttonText: t('home.pricing.hobby.button'),
tag: '', tag: '',
bg: '#f9fafb',
}, },
{ {
name: t('home.pricing.pro.name'), name: t('home.pricing.pro.name'),
@@ -201,7 +38,6 @@ const pricing = computed(() => ({
features: getFeatureList('home.pricing.pro.features'), features: getFeatureList('home.pricing.pro.features'),
buttonText: t('home.pricing.pro.button'), buttonText: t('home.pricing.pro.button'),
tag: t('home.pricing.pro.tag'), tag: t('home.pricing.pro.tag'),
bg: '#eff6ff',
}, },
{ {
name: t('home.pricing.scale.name'), name: t('home.pricing.scale.name'),
@@ -209,8 +45,329 @@ const pricing = computed(() => ({
features: getFeatureList('home.pricing.scale.features'), features: getFeatureList('home.pricing.scale.features'),
buttonText: t('home.pricing.scale.button'), buttonText: t('home.pricing.scale.button'),
tag: t('home.pricing.scale.tag'), tag: t('home.pricing.scale.tag'),
bg: '#eef4f7',
} }
] ]
})); }));
const featuredTag = computed(() => t('home.pricing.pro.tag'));
const scaleTag = computed(() => t('home.pricing.scale.tag'));
const isFeaturedPack = (tag: string) => tag === featuredTag.value;
const isScalePack = (tag: string) => tag === scaleTag.value;
</script> </script>
<template>
<div class="bg-white text-slate-900">
<section class="relative overflow-hidden border-b border-slate-100 bg-gradient-to-b from-slate-50 via-white to-white">
<div class="pointer-events-none absolute inset-0">
<div class="absolute inset-0 opacity-60 bg-[linear-gradient(rgba(148,163,184,0.12)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.12)_1px,transparent_1px)] bg-[length:64px_64px] [mask-image:linear-gradient(to_bottom,rgba(0,0,0,0.55),transparent_78%)]"></div>
<div class="absolute inset-x-0 top-0 h-[28rem] bg-[radial-gradient(circle_at_top,rgba(20,167,75,0.12),transparent_58%)]"></div>
<div class="absolute -left-16 top-28 h-56 w-56 rounded-full bg-primary/10 blur-3xl"></div>
<div class="absolute right-0 top-20 h-72 w-72 rounded-full bg-sky-100 blur-3xl"></div>
</div>
<div class="relative mx-auto max-w-7xl px-4 pb-18 pt-28 sm:px-6 lg:px-8 lg:pb-24 lg:pt-34">
<div class="grid items-center gap-12 lg:grid-cols-[1.02fr_0.98fr] lg:gap-14">
<div>
<div class="inline-flex items-center gap-2 rounded-full border border-primary/15 bg-white/90 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-primary shadow-sm">
<span class="h-2 w-2 rounded-full bg-primary"></span>
{{ t('home.features.live.onAir') }}
</div>
<h1 class="mt-7 max-w-4xl text-5xl font-bold leading-[1.02] tracking-tight text-slate-900 sm:text-6xl lg:text-7xl">
<span class="block">{{ t('home.hero.titleLine1') }}</span>
<span class="mt-2 block bg-[linear-gradient(135deg,#0f172a_0%,#14a74b_55%,#0ea5e9_100%)] bg-clip-text text-transparent">
{{ t('home.hero.titleLine2') }}
</span>
</h1>
<p class="mt-6 max-w-2xl text-lg leading-8 text-slate-600 lg:text-xl">
{{ t('home.hero.subtitle') }}
</p>
<div class="mt-9 flex flex-col gap-3 sm:flex-row">
<RouterLink to="/sign-up" class="btn btn-success !rounded-xl !px-6 !py-3.5 shadow-sm">
{{ t('home.hero.getStarted') }}
</RouterLink>
<RouterLink to="/login" class="btn btn-outline-primary !rounded-xl !px-6 !py-3.5">
{{ t('home.hero.uploadVideo') }}
</RouterLink>
</div>
<div class="mt-10 grid gap-4 sm:grid-cols-3">
<div
v-for="signal in signalItems"
:key="signal.label"
class="rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm"
>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{{ signal.label }}
</p>
<p class="mt-2 text-xl font-bold text-slate-900">
{{ signal.value }}
</p>
</div>
</div>
</div>
<div class="relative mx-auto w-full max-w-[36rem] lg:mr-0">
<div class="rounded-[2rem] border border-slate-200 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.08)] sm:p-6">
<div class="flex items-center justify-between border-b border-slate-100 pb-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{{ t('home.features.live.status') }}
</p>
<h2 class="mt-2 text-2xl font-bold tracking-tight text-slate-900">
{{ t('home.features.live.title') }}
</h2>
</div>
<span class="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
<span class="h-2 w-2 rounded-full bg-primary"></span>
{{ t('home.features.live.onAir') }}
</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600 sm:text-base">
{{ t('home.features.live.description') }}
</p>
<div class="mt-5 rounded-[1.5rem] bg-slate-50 p-4 sm:p-5">
<div class="space-y-3">
<div
v-for="signal in signalItems"
:key="signal.label"
class="flex items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3"
>
<span class="text-sm text-slate-500">{{ signal.label }}</span>
<span class="text-sm font-semibold text-slate-900">{{ signal.value }}</span>
</div>
</div>
<div class="mt-5 grid grid-cols-12 items-end gap-2">
<span
v-for="n in 12"
:key="n"
class="rounded-full bg-[linear-gradient(180deg,rgba(20,167,75,0.95),rgba(20,167,75,0.2))]"
:style="{ height: `${34 + (n % 5) * 12}px`, opacity: 0.4 + (n % 4) * 0.13 }"
/>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2.5">
<span
v-for="pill in featurePills"
:key="pill"
class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-600"
>
{{ pill }}
</span>
</div>
</div>
<div class="absolute -right-4 top-12 hidden w-56 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl lg:block">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{{ t('home.features.global.title') }}
</p>
<p class="mt-3 text-sm leading-6 text-slate-600">
{{ t('home.features.global.description') }}
</p>
</div>
<div class="absolute -left-4 bottom-8 hidden w-64 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl lg:block">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-slate-900">
{{ t('home.features.analytics.title') }}
</p>
<span class="rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500">
{{ t('home.features.live.onAir') }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-600">
{{ t('home.features.analytics.description') }}
</p>
<div class="mt-4 grid grid-cols-4 items-end gap-2">
<span class="h-10 rounded-full bg-slate-200"></span>
<span class="h-18 rounded-full bg-primary/70"></span>
<span class="h-14 rounded-full bg-sky-300"></span>
<span class="h-22 rounded-full bg-slate-900"></span>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="features" class="border-b border-slate-100 bg-slate-50/70 py-20">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mx-auto mb-14 max-w-3xl text-center">
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">
{{ t('home.features.heading') }}
</p>
<h2 class="mt-4 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
{{ t('home.features.heading') }}
</h2>
<p class="mt-4 text-lg leading-8 text-slate-600">
{{ t('home.features.subtitle') }}
</p>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-8 -258 529 532" fill="none">
<path
d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z"
fill="currentColor"
/>
</svg>
</div>
<h3 class="mt-5 text-2xl font-bold tracking-tight text-slate-900">
{{ t('home.features.global.title') }}
</h3>
<p class="mt-3 text-base leading-7 text-slate-600">
{{ t('home.features.global.description') }}
</p>
</article>
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-violet-50 text-violet-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 570 570" fill="none">
<path
d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z"
fill="#a6acb9"
/>
<path
d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z"
fill="#1e3050"
/>
</svg>
</div>
<h3 class="mt-5 text-2xl font-bold tracking-tight text-slate-900">
{{ t('home.features.encoding.title') }}
</h3>
<p class="mt-3 text-base leading-7 text-slate-600">
{{ t('home.features.encoding.description') }}
</p>
</article>
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-50 text-amber-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-10 -226 532 468" fill="none">
<path
d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z"
fill="currentColor"
/>
</svg>
</div>
<h3 class="mt-5 text-2xl font-bold tracking-tight text-slate-900">
{{ t('home.features.analytics.title') }}
</h3>
<p class="mt-3 text-base leading-7 text-slate-600">
{{ t('home.features.analytics.description') }}
</p>
</article>
</div>
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div class="grid gap-6 lg:grid-cols-[1fr_0.9fr] lg:items-center">
<div>
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">
{{ t('home.features.live.title') }}
</p>
<h3 class="mt-3 text-3xl font-bold tracking-tight text-slate-900">
{{ t('home.features.live.title') }}
</h3>
<p class="mt-4 max-w-2xl text-base leading-8 text-slate-600">
{{ t('home.features.live.description') }}
</p>
</div>
<div class="rounded-[1.75rem] bg-slate-50 p-4">
<div class="space-y-3">
<div
v-for="signal in signalItems"
:key="`summary-${signal.label}`"
class="flex items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3"
>
<span class="text-sm text-slate-500">{{ signal.label }}</span>
<span class="text-sm font-semibold text-slate-900">{{ signal.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="pricing" class="bg-white py-20">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mx-auto mb-14 max-w-3xl text-center">
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">
{{ pricing.title }}
</p>
<h2 class="mt-4 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
{{ pricing.title }}
</h2>
<p class="mt-4 text-lg leading-8 text-slate-600">
{{ pricing.subtitle }}
</p>
</div>
<div class="grid gap-6 lg:grid-cols-3 lg:items-stretch">
<article
v-for="pack in pricing.packs"
:key="pack.name"
:class="[
'relative flex h-full flex-col overflow-hidden rounded-3xl border p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]',
isFeaturedPack(pack.tag)
? 'border-primary bg-primary/[0.04] ring-1 ring-primary/15'
: isScalePack(pack.tag)
? 'border-slate-200 bg-slate-50/80'
: 'border-slate-200 bg-white'
]"
>
<div
v-if="pack.tag"
:class="[
'absolute right-5 top-5 rounded-full px-3 py-1 text-xs font-semibold',
isFeaturedPack(pack.tag) ? 'bg-primary text-white' : 'bg-slate-900 text-white'
]"
>
{{ pack.tag }}
</div>
<div>
<p class="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
{{ pack.name }}
</p>
<div class="mt-5 flex items-end gap-2">
<span class="text-5xl font-bold tracking-tight text-slate-900">{{ pack.price }}</span>
<span class="pb-2 text-slate-500">{{ t('home.pricing.perMonth') }}</span>
</div>
</div>
<ul class="mt-8 space-y-4">
<li v-for="value in pack.features" :key="value" class="flex items-start gap-3">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 12.5l4.2 4.2L19 7" />
</svg>
</span>
<span class="text-slate-600">{{ value }}</span>
</li>
</ul>
<RouterLink
to="/sign-up"
:class="[
'mt-10 inline-flex w-full items-center justify-center !rounded-xl !py-3.5',
isFeaturedPack(pack.tag) ? 'btn btn-success' : 'btn btn-outline-primary'
]"
>
{{ pack.buttonText }}
</RouterLink>
</article>
</div>
</div>
</section>
</div>
</template>

View File

@@ -1,23 +1,52 @@
<template> <template>
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;"> <section class="relative overflow-hidden border-b border-slate-100 bg-gradient-to-b from-slate-50 via-white to-white">
<div class="grow pt-32 pb-12 px-4"> <div class="pointer-events-none absolute inset-0">
<div class="max-w-4xl mx-auto space-y-10"> <div class="absolute inset-0 opacity-55 bg-[linear-gradient(rgba(148,163,184,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.1)_1px,transparent_1px)] bg-[length:64px_64px] [mask-image:linear-gradient(to_bottom,rgba(0,0,0,0.45),transparent_78%)]"></div>
<div class="space-y-3"> <div class="absolute left-0 top-20 h-56 w-56 rounded-full bg-primary/10 blur-3xl"></div>
<p <div class="absolute right-0 top-24 h-64 w-64 rounded-full bg-sky-100 blur-3xl"></div>
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase"> </div>
{{ pageContent.data.pageSubheading }}</p>
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1> <div class="relative mx-auto max-w-5xl px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pb-20 lg:pt-34">
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p> <div class="mx-auto max-w-4xl">
<div class="inline-flex items-center rounded-full border border-primary/15 bg-white/90 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-primary shadow-sm">
{{ pageContent.data.pageSubheading }}
</div> </div>
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
<section v-for="(item, index) in pageContent.data.list" :key="index"> <div class="mt-6 max-w-3xl space-y-4">
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2> <h1 class="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl lg:text-6xl">
<p class="leading-relaxed">{{ item.text }}</p> {{ pageContent.data.pageHeading }}
</section> </h1>
<p class="text-lg leading-8 text-slate-600">
{{ pageContent.data.description }}
</p>
</div>
<div class="mt-10 rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm sm:p-8 lg:p-10">
<div class="space-y-6">
<section
v-for="(item, index) in pageContent.data.list"
:key="index"
class="rounded-2xl border border-slate-200 bg-slate-50/70 p-5 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-primary/25 hover:bg-white hover:shadow-[0_14px_32px_rgba(15,23,42,0.06)] sm:p-6"
>
<div class="flex items-start gap-4">
<div class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-sm font-bold text-primary">
{{ index + 1 }}
</div>
<div class="min-w-0">
<h2 class="text-xl font-bold tracking-tight text-slate-900 sm:text-2xl">
{{ item.heading }}
</h2>
<p class="mt-3 leading-8 text-slate-600">
{{ item.text }}
</p>
</div>
</div>
</section>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';

View File

@@ -1,23 +1,52 @@
<template> <template>
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;"> <section class="relative overflow-hidden border-b border-slate-100 bg-gradient-to-b from-slate-50 via-white to-white">
<div class="grow pt-32 pb-12 px-4"> <div class="pointer-events-none absolute inset-0">
<div class="max-w-4xl mx-auto space-y-10"> <div class="absolute inset-0 opacity-55 bg-[linear-gradient(rgba(148,163,184,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.1)_1px,transparent_1px)] bg-[length:64px_64px] [mask-image:linear-gradient(to_bottom,rgba(0,0,0,0.45),transparent_78%)]"></div>
<div class="space-y-3"> <div class="absolute left-0 top-20 h-56 w-56 rounded-full bg-primary/10 blur-3xl"></div>
<p <div class="absolute right-0 top-24 h-64 w-64 rounded-full bg-sky-100 blur-3xl"></div>
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase"> </div>
{{ pageContent.data.pageSubheading }}</p>
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1> <div class="relative mx-auto max-w-5xl px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pb-20 lg:pt-34">
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p> <div class="mx-auto max-w-4xl">
<div class="inline-flex items-center rounded-full border border-primary/15 bg-white/90 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-primary shadow-sm">
{{ pageContent.data.pageSubheading }}
</div> </div>
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
<section v-for="(item, index) in pageContent.data.list" :key="index"> <div class="mt-6 max-w-3xl space-y-4">
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2> <h1 class="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl lg:text-6xl">
<p class="leading-relaxed">{{ item.text }}</p> {{ pageContent.data.pageHeading }}
</section> </h1>
<p class="text-lg leading-8 text-slate-600">
{{ pageContent.data.description }}
</p>
</div>
<div class="mt-10 rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm sm:p-8 lg:p-10">
<div class="space-y-6">
<section
v-for="(item, index) in pageContent.data.list"
:key="index"
class="rounded-2xl border border-slate-200 bg-slate-50/70 p-5 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-primary/25 hover:bg-white hover:shadow-[0_14px_32px_rgba(15,23,42,0.06)] sm:p-6"
>
<div class="flex items-start gap-4">
<div class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-sm font-bold text-primary">
{{ index + 1 }}
</div>
<div class="min-w-0">
<h2 class="text-xl font-bold tracking-tight text-slate-900 sm:text-2xl">
{{ item.heading }}
</h2>
<p class="mt-3 leading-8 text-slate-600">
{{ item.text }}
</p>
</div>
</div>
</section>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';

View File

@@ -156,7 +156,7 @@ const routes: RouteData[] = [
{ {
path: "security", path: "security",
name: "settings-security", name: "settings-security",
component: () => import("./settings/pages/SecurityNConnected.vue"), component: () => import("./settings/SecurityNConnected/SecurityNConnected.vue"),
meta: { meta: {
head: { head: {
title: "Security & Connected Apps - Holistream", title: "Security & Connected Apps - Holistream",
@@ -166,7 +166,7 @@ const routes: RouteData[] = [
{ {
path: "billing", path: "billing",
name: "settings-billing", name: "settings-billing",
component: () => import("./settings/pages/Billing.vue"), component: () => import("./settings/Billing/Billing.vue"),
meta: { meta: {
head: { head: {
title: "Billing & Plans - Holistream", title: "Billing & Plans - Holistream",
@@ -182,7 +182,7 @@ const routes: RouteData[] = [
{ {
path: "notifications", path: "notifications",
name: "settings-notifications", name: "settings-notifications",
component: () => import("./settings/pages/NotificationSettings.vue"), component: () => import("./settings/NotificationSettings/NotificationSettings.vue"),
meta: { meta: {
head: { head: {
title: "Notifications - Holistream", title: "Notifications - Holistream",
@@ -192,7 +192,7 @@ const routes: RouteData[] = [
{ {
path: "player", path: "player",
name: "settings-player", name: "settings-player",
component: () => import("./settings/pages/PlayerSettings.vue"), component: () => import("./settings/PlayerSettings/PlayerSettings.vue"),
meta: { meta: {
head: { head: {
title: "Player Settings - Holistream", title: "Player Settings - Holistream",
@@ -202,7 +202,7 @@ const routes: RouteData[] = [
{ {
path: "domains", path: "domains",
name: "settings-domains", name: "settings-domains",
component: () => import("./settings/pages/DomainsDns.vue"), component: () => import("./settings/DomainsDns/DomainsDns.vue"),
meta: { meta: {
head: { head: {
title: "Allowed Domains - Holistream", title: "Allowed Domains - Holistream",
@@ -212,7 +212,7 @@ const routes: RouteData[] = [
{ {
path: "ads", path: "ads",
name: "settings-ads", name: "settings-ads",
component: () => import("./settings/pages/AdsVast.vue"), component: () => import("./settings/AdsVast/AdsVast.vue"),
meta: { meta: {
head: { head: {
title: "Ads & VAST - Holistream", title: "Ads & VAST - Holistream",
@@ -222,7 +222,7 @@ const routes: RouteData[] = [
{ {
path: "danger", path: "danger",
name: "settings-danger", name: "settings-danger",
component: () => import("./settings/pages/DangerZone.vue"), component: () => import("./settings/DangerZone/DangerZone.vue"),
meta: { meta: {
head: { head: {
title: "Danger Zone - Holistream", title: "Danger Zone - Holistream",

View File

@@ -2,7 +2,6 @@
import { client, type ModelVideo } from '@/api/client'; import { client, type ModelVideo } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import NameGradient from './components/NameGradient.vue'; import NameGradient from './components/NameGradient.vue';
import QuickActions from './components/QuickActions.vue'; import QuickActions from './components/QuickActions.vue';
import RecentVideos from './components/RecentVideos.vue'; import RecentVideos from './components/RecentVideos.vue';
@@ -10,7 +9,6 @@ import StatsOverview from './components/StatsOverview.vue';
const loading = ref(true); const loading = ref(true);
const recentVideos = ref<ModelVideo[]>([]); const recentVideos = ref<ModelVideo[]>([]);
const { t } = useTranslation();
const stats = ref({ const stats = ref({
totalVideos: 0, totalVideos: 0,
@@ -55,8 +53,8 @@ onMounted(() => {
<template> <template>
<div class="dashboard-overview"> <div class="dashboard-overview">
<PageHeader :title="NameGradient" :description="t('overview.pageHeaderDescription')" :breadcrumbs="[ <PageHeader :title="NameGradient" :description="$t('overview.welcome.subtitle')" :breadcrumbs="[
{ label: t('pageHeader.dashboard') } { label: $t('pageHeader.dashboard') }
]" /> ]" />
<StatsOverview :loading="loading" :stats="stats" /> <StatsOverview :loading="loading" :stats="stats" />

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="text-3xl font-bold text-gray-900 mb-1"> <div class="text-3xl font-bold text-gray-900 mb-1">
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ t('overview.nameGradient.hello', { name: auth.user?.username || t('app.name') }) }}</span> <span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ $t('overview.welcome.title', { name: auth.user?.username || t('app.name') }) }}</span>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -8,5 +8,4 @@ import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
const auth = useAuthStore(); const auth = useAuthStore();
const { t } = useTranslation();
</script> </script>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
import StatsCard from '@/components/dashboard/StatsCard.vue'; import StatsCard from '@/components/dashboard/StatsCard.vue';
import { formatBytes } from '@/lib/utils'; import { formatBytes } from '@/lib/utils';
@@ -14,8 +15,9 @@ interface Props {
}; };
} }
defineProps<Props>(); const props = defineProps<Props>();
const { t } = useTranslation(); const { t, i18next } = useTranslation();
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
</script> </script>
<template> <template>
@@ -34,7 +36,7 @@ const { t } = useTranslation();
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" /> <StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
<StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString()" <StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString(localeTag)"
:trend="{ value: 8, isPositive: true }" /> :trend="{ value: 8, isPositive: true }" />
<StatsCard :title="t('overview.stats.storageUsed')" <StatsCard :title="t('overview.stats.storageUsed')"

View File

@@ -4,13 +4,14 @@ import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue'; import AppInput from '@/components/app/AppInput.vue';
import AppSwitch from '@/components/app/AppSwitch.vue'; import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue'; import LinkIcon from '@/components/icons/LinkIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue'; import PencilIcon from '@/components/icons/PencilIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue'; import PlusIcon from '@/components/icons/PlusIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue'; import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm'; import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
@@ -221,32 +222,25 @@ const getAdFormatColor = (format: string) => {
</script> </script>
<template> <template>
<div class="bg-surface border border-border rounded-lg"> <SettingsSectionCard
<div class="px-6 py-4 border-b border-border flex items-center justify-between"> :title="t('settings.content.ads.title')"
<div> :description="t('settings.content.ads.subtitle')"
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.ads.title') }}</h2> bodyClass=""
<p class="text-sm text-foreground/60 mt-0.5"> >
{{ t('settings.content.ads.subtitle') }} <template #header-actions>
</p>
</div>
<AppButton size="sm" @click="openAddDialog"> <AppButton size="sm" @click="openAddDialog">
<template #icon> <template #icon>
<PlusIcon class="w-4 h-4" /> <PlusIcon class="w-4 h-4" />
</template> </template>
{{ t('settings.adsVast.createTemplate') }} {{ t('settings.adsVast.createTemplate') }}
</AppButton> </AppButton>
</div> </template>
<div class="px-6 py-3 bg-info/5 border-b border-info/20"> <SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
<div class="flex items-start gap-2"> {{ t('settings.adsVast.infoBanner') }}
<InfoIcon class="w-4 h-4 text-info mt-0.5" /> </SettingsNotice>
<div class="text-xs text-foreground/70">
{{ t('settings.adsVast.infoBanner') }}
</div>
</div>
</div>
<div class="border-b border-border"> <div class="border-b border-border mt-4">
<table class="w-full"> <table class="w-full">
<thead class="bg-muted/30"> <thead class="bg-muted/30">
<tr> <tr>
@@ -389,5 +383,5 @@ const getAdFormatColor = (format: string) => {
</div> </div>
</template> </template>
</AppDialog> </AppDialog>
</div> </SettingsSectionCard>
</template> </template>

View File

@@ -0,0 +1,282 @@
<script setup lang="ts">
import { client, type ModelPlan } from '@/api/client';
import { useAppToast } from '@/composables/useAppToast';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue';
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue';
import BillingTopupDialog from '@/routes/settings/components/billing/BillingTopupDialog.vue';
import BillingUsageSection from '@/routes/settings/components/billing/BillingUsageSection.vue';
import BillingWalletRow from '@/routes/settings/components/billing/BillingWalletRow.vue';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import { useTranslation } from 'i18next-vue';
import { computed, ref } from 'vue';
const toast = useAppToast();
const auth = useAuthStore();
const { t, i18next } = useTranslation();
const { data, isLoading } = useQuery({
key: () => ['payments-and-plans'],
query: () => client.plans.plansList(),
});
const subscribing = ref<string | null>(null);
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(0);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
type PaymentHistoryItem = {
id: string;
date: string;
amount: number;
plan: string;
status: string;
invoiceId: string;
};
const paymentHistory = ref<PaymentHistoryItem[]>([
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
]);
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const uploadsUsed = ref(12);
const uploadsLimit = ref(50);
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
const currentPlanId = computed(() => {
if (auth.user?.plan_id) return auth.user.plan_id;
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id;
return undefined;
});
const plans = computed(() => data.value?.data?.data.plans || []);
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
);
const uploadsPercentage = computed(() =>
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100)
);
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
const currencyFormatter = computed(() => new Intl.NumberFormat(localeTag.value, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
}));
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return `${new Intl.NumberFormat(localeTag.value).format(value)} ${sizes[i]}`;
};
const formatDuration = (seconds?: number) => {
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
return t('settings.billing.durationMinutes', { minutes: Math.floor(seconds / 60) });
};
const getStatusStyles = (status: string) => {
switch (status) {
case 'success':
return 'bg-success/10 text-success';
case 'failed':
return 'bg-danger/10 text-danger';
case 'pending':
return 'bg-warning/10 text-warning';
default:
return 'bg-info/10 text-info';
}
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
success: t('settings.billing.status.success'),
failed: t('settings.billing.status.failed'),
pending: t('settings.billing.status.pending'),
};
return map[status] || status;
};
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit });
const subscribe = async (plan: ModelPlan) => {
if (!plan.id) return;
subscribing.value = plan.id;
try {
await client.payments.paymentsCreate({
amount: plan.price || 0,
plan_id: plan.id,
});
toast.add({
severity: 'success',
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
detail: t('settings.billing.toast.subscriptionSuccessDetail', { plan: plan.name || '' }),
life: 3000,
});
paymentHistory.value.unshift({
id: `inv_${Date.now()}`,
date: new Date().toLocaleDateString(localeTag.value, { month: 'short', day: 'numeric', year: 'numeric' }),
amount: plan.price || 0,
plan: plan.name || t('settings.billing.unknownPlan'),
status: 'success',
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`,
});
} catch (err: any) {
console.error(err);
toast.add({
severity: 'error',
summary: t('settings.billing.toast.subscriptionFailedSummary'),
detail: err.message || t('settings.billing.toast.subscriptionFailedDetail'),
life: 5000,
});
} finally {
subscribing.value = null;
}
};
const handleTopup = async (amount: number) => {
topupLoading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 1500));
toast.add({
severity: 'success',
summary: t('settings.billing.toast.topupSuccessSummary'),
detail: t('settings.billing.toast.topupSuccessDetail', { amount: formatMoney(amount) }),
life: 3000,
});
topupDialogVisible.value = false;
topupAmount.value = null;
} catch (e: any) {
toast.add({
severity: 'error',
summary: t('settings.billing.toast.topupFailedSummary'),
detail: e.message || t('settings.billing.toast.topupFailedDetail'),
life: 5000,
});
} finally {
topupLoading.value = false;
}
};
const handleDownloadInvoice = (item: PaymentHistoryItem) => {
toast.add({
severity: 'info',
summary: t('settings.billing.toast.downloadingSummary'),
detail: t('settings.billing.toast.downloadingDetail', { invoiceId: item.invoiceId }),
life: 2000,
});
setTimeout(() => {
toast.add({
severity: 'success',
summary: t('settings.billing.toast.downloadedSummary'),
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
life: 3000,
});
}, 1500);
};
const openTopupDialog = () => {
topupAmount.value = null;
topupDialogVisible.value = true;
};
const selectPreset = (amount: number) => {
topupAmount.value = amount;
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.content.billing.title')"
:description="t('settings.content.billing.subtitle')"
>
<BillingWalletRow
:title="t('settings.billing.walletBalance')"
:description="t('settings.billing.currentBalance', { balance: formatMoney(walletBalance) })"
:button-label="t('settings.billing.topUp')"
@topup="openTopupDialog"
/>
<BillingPlansSection
:title="t('settings.billing.availablePlans')"
:description="t('settings.billing.availablePlansHint')"
:is-loading="isLoading"
:plans="plans"
:current-plan-id="currentPlanId"
:subscribing="subscribing"
:format-money="formatMoney"
:get-plan-storage-text="getPlanStorageText"
:get-plan-duration-text="getPlanDurationText"
:get-plan-uploads-text="getPlanUploadsText"
:current-plan-label="t('settings.billing.currentPlan')"
:processing-label="t('settings.billing.processing')"
:upgrade-label="t('settings.billing.upgrade')"
@subscribe="subscribe"
/>
<BillingUsageSection
:storage-title="t('settings.billing.storage')"
:storage-description="t('settings.billing.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) })"
:storage-percentage="storagePercentage"
:uploads-title="t('settings.billing.monthlyUploads')"
:uploads-description="t('settings.billing.uploadsUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit })"
:uploads-percentage="uploadsPercentage"
/>
<BillingHistorySection
:title="t('settings.billing.paymentHistory')"
:description="t('settings.billing.paymentHistorySubtitle')"
:items="paymentHistory"
:format-money="formatMoney"
:get-status-styles="getStatusStyles"
:get-status-label="getStatusLabel"
:date-label="t('settings.billing.table.date')"
:amount-label="t('settings.billing.table.amount')"
:plan-label="t('settings.billing.table.plan')"
:status-label="t('settings.billing.table.status')"
:invoice-label="t('settings.billing.table.invoice')"
:empty-label="t('settings.billing.noPaymentHistory')"
:download-label="t('settings.billing.download')"
@download="handleDownloadInvoice"
/>
</SettingsSectionCard>
<BillingTopupDialog
:visible="topupDialogVisible"
:title="t('settings.billing.topupDialog.title')"
:subtitle="t('settings.billing.topupDialog.subtitle')"
:presets="topupPresets"
:amount="topupAmount"
:loading="topupLoading"
:custom-amount-label="t('settings.billing.topupDialog.customAmount')"
:amount-placeholder="t('settings.billing.topupDialog.enterAmount')"
:hint="t('settings.billing.topupDialog.hint')"
:cancel-label="t('common.cancel')"
:proceed-label="t('settings.billing.topupDialog.proceed')"
:format-money="formatMoney"
@update:visible="topupDialogVisible = $event"
@update:amount="topupAmount = $event"
@selectPreset="selectPreset"
@submit="handleTopup(topupAmount || 0)"
/>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { useTranslation } from 'i18next-vue';
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
const handleDeleteAccount = () => {
confirm.require({
message: t('settings.dangerZone.confirm.deleteAccountMessage'),
header: t('settings.dangerZone.confirm.deleteAccountHeader'),
acceptLabel: t('settings.dangerZone.confirm.deleteAccountAccept'),
rejectLabel: t('settings.dangerZone.confirm.deleteAccountReject'),
accept: () => {
toast.add({
severity: 'info',
summary: t('settings.dangerZone.toast.deleteAccountSummary'),
detail: t('settings.dangerZone.toast.deleteAccountDetail'),
life: 5000,
});
},
});
};
const handleClearData = () => {
confirm.require({
message: t('settings.dangerZone.confirm.clearDataMessage'),
header: t('settings.dangerZone.confirm.clearDataHeader'),
acceptLabel: t('settings.dangerZone.confirm.clearDataAccept'),
rejectLabel: t('settings.dangerZone.confirm.clearDataReject'),
accept: () => {
toast.add({
severity: 'info',
summary: t('settings.dangerZone.toast.clearDataSummary'),
detail: t('settings.dangerZone.toast.clearDataDetail'),
life: 5000,
});
},
});
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.content.danger.title')"
:description="t('settings.content.danger.subtitle')"
titleClass="text-base font-semibold text-danger"
>
<SettingsRow
:title="t('settings.dangerZone.deleteAccount.title')"
:description="t('settings.dangerZone.deleteAccount.description')"
iconBoxClass="bg-danger/10"
hoverClass="hover:bg-danger/5"
>
<template #icon>
<AlertTriangleIcon class="w-5 h-5 text-danger" />
</template>
<template #actions>
<AppButton variant="danger" size="sm" @click="handleDeleteAccount">
<template #icon>
<TrashIcon class="w-4 h-4" />
</template>
{{ t('settings.dangerZone.deleteAccount.button') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.dangerZone.clearData.title')"
:description="t('settings.dangerZone.clearData.description')"
iconBoxClass="bg-danger/10"
hoverClass="hover:bg-danger/5"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</template>
<template #actions>
<AppButton variant="danger" size="sm" @click="handleClearData">
<template #icon>
<SlidersIcon class="w-4 h-4" />
</template>
{{ t('settings.dangerZone.clearData.button') }}
</AppButton>
</template>
</SettingsRow>
<SettingsNotice tone="warning" class="mx-6 my-4 border-warning/30">
<p class="font-medium text-foreground mb-1">{{ t('settings.dangerZone.warning.title') }}</p>
<p>{{ t('settings.dangerZone.warning.description') }}</p>
</SettingsNotice>
</SettingsSectionCard>
</template>

View File

@@ -2,14 +2,14 @@
import AppButton from '@/components/app/AppButton.vue'; import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue'; import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue'; import AppInput from '@/components/app/AppInput.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue'; import LinkIcon from '@/components/icons/LinkIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue'; import PlusIcon from '@/components/icons/PlusIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue'; import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm'; import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
@@ -99,32 +99,25 @@ const copyIframeCode = () => {
</script> </script>
<template> <template>
<div class="bg-surface border border-border rounded-lg"> <SettingsSectionCard
<div class="px-6 py-4 border-b border-border flex items-center justify-between"> :title="t('settings.content.domains.title')"
<div> :description="t('settings.content.domains.subtitle')"
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.domains.title') }}</h2> bodyClass=""
<p class="text-sm text-foreground/60 mt-0.5"> >
{{ t('settings.content.domains.subtitle') }} <template #header-actions>
</p>
</div>
<AppButton size="sm" @click="showAddDialog = true"> <AppButton size="sm" @click="showAddDialog = true">
<template #icon> <template #icon>
<PlusIcon class="w-4 h-4" /> <PlusIcon class="w-4 h-4" />
</template> </template>
{{ t('settings.domainsDns.addDomain') }} {{ t('settings.domainsDns.addDomain') }}
</AppButton> </AppButton>
</div> </template>
<div class="px-6 py-3 bg-info/5 border-b border-info/20"> <SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
<div class="flex items-start gap-2"> {{ t('settings.domainsDns.infoBanner') }}
<InfoIcon class="w-4 h-4 text-info mt-0.5" /> </SettingsNotice>
<div class="text-xs text-foreground/70">
{{ t('settings.domainsDns.infoBanner') }}
</div>
</div>
</div>
<div class="border-b border-border"> <div class="border-b border-border mt-4">
<table class="w-full"> <table class="w-full">
<thead class="bg-muted/30"> <thead class="bg-muted/30">
<tr> <tr>
@@ -199,15 +192,13 @@ const copyIframeCode = () => {
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p> <p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
</div> </div>
<div class="bg-warning/5 border border-warning/20 rounded-md p-3"> <SettingsNotice
<div class="flex items-start gap-2"> tone="warning"
<AlertTriangleIcon class="w-4 h-4 text-warning mt-0.5" /> :title="t('settings.domainsDns.dialog.importantTitle')"
<div class="text-xs text-foreground/70"> class="p-3"
<p class="font-medium text-foreground mb-1">{{ t('settings.domainsDns.dialog.importantTitle') }}</p> >
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p> <p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
</div> </SettingsNotice>
</div>
</div>
</div> </div>
<template #footer> <template #footer>
@@ -222,5 +213,5 @@ const copyIframeCode = () => {
</AppButton> </AppButton>
</template> </template>
</AppDialog> </AppDialog>
</div> </SettingsSectionCard>
</template> </template>

View File

@@ -7,6 +7,8 @@ import MailIcon from '@/components/icons/MailIcon.vue';
import SendIcon from '@/components/icons/SendIcon.vue'; import SendIcon from '@/components/icons/SendIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue'; import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
@@ -81,45 +83,33 @@ const handleSave = async () => {
</script> </script>
<template> <template>
<div class="bg-surface border border-border rounded-lg"> <SettingsSectionCard
<div class="px-6 py-4 border-b border-border flex items-center justify-between"> :title="t('settings.content.notifications.title')"
<div> :description="t('settings.content.notifications.subtitle')"
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.notifications.title') }}</h2> >
<p class="text-sm text-foreground/60 mt-0.5"> <template #header-actions>
{{ t('settings.content.notifications.subtitle') }} <AppButton size="sm" :loading="saving" @click="handleSave">
</p>
</div>
<AppButton
size="sm"
:loading="saving"
@click="handleSave"
>
<template #icon> <template #icon>
<CheckIcon class="w-4 h-4" /> <CheckIcon class="w-4 h-4" />
</template> </template>
{{ t('settings.notificationSettings.saveChanges') }} {{ t('settings.notificationSettings.saveChanges') }}
</AppButton> </AppButton>
</div> </template>
<div class="divide-y divide-border"> <SettingsRow
<div v-for="type in notificationTypes"
v-for="type in notificationTypes" :key="type.key"
:key="type.key" :title="type.title"
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all" :description="type.description"
> :iconBoxClass="type.bgColor"
<div class="flex items-center gap-4"> >
<div <template #icon>
:class="`:uno: w-10 h-10 rounded-md flex items-center justify-center shrink-0 ${type.bgColor}`" <component :is="type.icon" :class="[type.iconColor, 'w-5 h-5']" />
> </template>
<component :is="type.icon" :class="`${type.iconColor} w-5 h-5`" />
</div> <template #actions>
<div>
<p class="text-sm font-medium text-foreground">{{ type.title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ type.description }}</p>
</div>
</div>
<AppSwitch v-model="notificationSettings[type.key]" /> <AppSwitch v-model="notificationSettings[type.key]" />
</div> </template>
</div> </SettingsRow>
</div> </SettingsSectionCard>
</template> </template>

View File

@@ -5,6 +5,8 @@ import AppButton from '@/components/app/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue'; import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
const toast = useAppToast(); const toast = useAppToast();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -24,7 +26,6 @@ const saving = ref(false);
const handleSave = async () => { const handleSave = async () => {
saving.value = true; saving.value = true;
try { try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
toast.add({ toast.add({
severity: 'success', severity: 'success',
@@ -91,47 +92,33 @@ const settingsItems = computed(() => [
</script> </script>
<template> <template>
<div class="bg-surface border border-border rounded-lg"> <SettingsSectionCard
<!-- Header --> :title="t('settings.content.player.title')"
<div class="px-6 py-4 border-b border-border flex items-center justify-between"> :description="t('settings.content.player.subtitle')"
<div> >
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.player.title') }}</h2> <template #header-actions>
<p class="text-sm text-foreground/60 mt-0.5"> <AppButton size="sm" :loading="saving" @click="handleSave">
{{ t('settings.content.player.subtitle') }}
</p>
</div>
<AppButton
size="sm"
:loading="saving"
@click="handleSave"
>
<template #icon> <template #icon>
<CheckIcon class="w-4 h-4" /> <CheckIcon class="w-4 h-4" />
</template> </template>
{{ t('common.save') }} {{ t('common.save') }}
</AppButton> </AppButton>
</div> </template>
<!-- Content --> <SettingsRow
<div class="divide-y divide-border"> v-for="item in settingsItems"
<div :key="item.key"
v-for="item in settingsItems" :title="item.title"
:key="item.key" :description="item.description"
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all" iconBoxClass="bg-primary/10 text-primary"
> >
<div class="flex items-center gap-4"> <template #icon>
<div <span v-html="item.svg" />
:class="`:uno: w-10 h-10 rounded-md flex items-center justify-center shrink-0 bg-primary/10 text-primary`" </template>
>
<span v-html="item.svg" /> <template #actions>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ item.title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ item.description }}</p>
</div>
</div>
<AppSwitch v-model="playerSettings[item.key]" /> <AppSwitch v-model="playerSettings[item.key]" />
</div> </template>
</div> </SettingsRow>
</div> </SettingsSectionCard>
</template> </template>

View File

@@ -0,0 +1,517 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { supportedLocales } from '@/i18n/constants';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
import { computed, ref } from 'vue';
const auth = useAuthStore();
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
const languageSaving = ref(false);
const selectedLanguage = ref('en');
const languageOptions = computed(() => supportedLocales.map((value) => ({
value,
label: t(`settings.securityConnected.language.options.${value}`)
})));
const twoFactorEnabled = ref(false);
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
const emailConnected = ref(true);
const telegramConnected = ref(false);
const telegramUsername = ref('');
const changePasswordDialogVisible = ref(false);
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const changePasswordLoading = ref(false);
const changePasswordError = ref('');
const openChangePassword = () => {
changePasswordDialogVisible.value = true;
changePasswordError.value = '';
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
};
const changePassword = async () => {
changePasswordError.value = '';
if (newPassword.value !== confirmPassword.value) {
changePasswordError.value = t('settings.securityConnected.changePassword.dialog.errors.mismatch');
return;
}
if (newPassword.value.length < 6) {
changePasswordError.value = t('settings.securityConnected.changePassword.dialog.errors.minLength');
return;
}
changePasswordLoading.value = true;
try {
await auth.changePassword(currentPassword.value, newPassword.value);
changePasswordDialogVisible.value = false;
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
toast.add({
severity: 'success',
summary: t('settings.securityConnected.changePassword.toast.successSummary'),
detail: t('settings.securityConnected.changePassword.toast.successDetail'),
life: 3000
});
} catch (e: any) {
changePasswordError.value = e.message || t('settings.securityConnected.changePassword.dialog.errors.default');
} finally {
changePasswordLoading.value = false;
}
};
const handleLogout = () => {
confirm.require({
message: t('settings.securityConnected.logout.confirm.message'),
header: t('settings.securityConnected.logout.confirm.header'),
acceptLabel: t('settings.securityConnected.logout.confirm.accept'),
rejectLabel: t('settings.securityConnected.logout.confirm.reject'),
accept: async () => {
await auth.logout();
}
});
};
const saveLanguage = async () => {
languageSaving.value = true;
try {
const result = await auth.setLanguage(selectedLanguage.value);
if (result.ok && !result.fallbackOnly) {
toast.add({
severity: 'success',
summary: t('settings.securityConnected.language.toast.successSummary'),
detail: t('settings.securityConnected.language.toast.successDetail'),
life: 3000,
});
return;
}
toast.add({
severity: 'warn',
summary: t('settings.securityConnected.language.toast.errorSummary'),
detail: t('settings.securityConnected.language.toast.errorDetail'),
life: 5000,
});
} finally {
languageSaving.value = false;
}
};
const handleToggle2FA = async () => {
if (!twoFactorEnabled.value) {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorDialogVisible.value = true;
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorEnableFailedSummary'),
detail: t('settings.securityConnected.toast.twoFactorEnableFailedDetail'),
life: 5000
});
twoFactorEnabled.value = false;
}
} else {
try {
await new Promise(resolve => setTimeout(resolve, 500));
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.twoFactorDisabledSummary'),
detail: t('settings.securityConnected.toast.twoFactorDisabledDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorDisableFailedSummary'),
detail: t('settings.securityConnected.toast.twoFactorDisableFailedDetail'),
life: 5000
});
twoFactorEnabled.value = true;
}
}
};
const confirmTwoFactor = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorEnabled.value = true;
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.twoFactorEnabledSummary'),
detail: t('settings.securityConnected.toast.twoFactorEnabledDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorInvalidCodeSummary'),
detail: t('settings.securityConnected.toast.twoFactorInvalidCodeDetail'),
life: 5000
});
}
};
const connectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
telegramConnected.value = true;
telegramUsername.value = '@telegram_user';
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.telegramConnectedSummary'),
detail: t('settings.securityConnected.toast.telegramConnectedDetail', { username: telegramUsername.value }),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.telegramConnectFailedSummary'),
detail: t('settings.securityConnected.toast.telegramConnectFailedDetail'),
life: 5000
});
}
};
const disconnectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
telegramConnected.value = false;
telegramUsername.value = '';
toast.add({
severity: 'info',
summary: t('settings.securityConnected.toast.telegramDisconnectedSummary'),
detail: t('settings.securityConnected.toast.telegramDisconnectedDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.telegramDisconnectFailedSummary'),
detail: t('settings.securityConnected.toast.telegramDisconnectFailedDetail'),
life: 5000
});
}
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.securityConnected.header.title')"
:description="t('settings.securityConnected.header.subtitle')"
>
<SettingsRow
:title="t('settings.securityConnected.accountStatus.label')"
:description="t('settings.securityConnected.accountStatus.detail')"
iconBoxClass="bg-success/10"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</template>
<template #actions>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.language.label')"
:description="t('settings.securityConnected.language.detail')"
iconBoxClass="bg-info/10"
actionsClass="flex items-center gap-2"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15 15 0 0 1 0 20" />
<path d="M12 2a15 15 0 0 0 0 20" />
</svg>
</template>
<template #actions>
<select
v-model="selectedLanguage"
:disabled="languageSaving"
class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground disabled:opacity-60"
>
<option
v-for="option in languageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<AppButton
size="sm"
:loading="languageSaving"
:disabled="languageSaving"
@click="saveLanguage"
>
{{ t('settings.securityConnected.language.save') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.twoFactor.label')"
:description="twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled')"
iconBoxClass="bg-primary/10"
>
<template #icon>
<LockIcon class="w-5 h-5 text-primary" />
</template>
<template #actions>
<AppSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" />
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.changePassword.label')"
:description="t('settings.securityConnected.changePassword.detail')"
iconBoxClass="bg-primary/10"
>
<template #icon>
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</template>
<template #actions>
<AppButton size="sm" @click="openChangePassword">
{{ t('settings.securityConnected.changePassword.button') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.logout.label')"
:description="t('settings.securityConnected.logout.detail')"
iconBoxClass="bg-danger/10"
hoverClass="hover:bg-danger/5"
>
<template #icon>
<XCircleIcon class="w-5 h-5 text-danger" />
</template>
<template #actions>
<AppButton variant="danger" size="sm" @click="handleLogout">
<template #icon>
<XCircleIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.logout.button') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.email.label')"
:description="emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected')"
iconBoxClass="bg-info/10"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</template>
<template #actions>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? t('settings.securityConnected.email.badgeConnected') : t('settings.securityConnected.email.badgeDisconnected') }}
</span>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.telegram.label')"
:description="telegramConnected ? (telegramUsername || t('settings.securityConnected.telegram.connectedFallback')) : t('settings.securityConnected.telegram.detailDisconnected')"
iconBoxClass="bg-[#0088cc]/10"
>
<template #icon>
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</template>
<template #actions>
<AppButton
v-if="telegramConnected"
variant="danger"
size="sm"
@click="disconnectTelegram"
>
{{ t('settings.securityConnected.telegram.disconnect') }}
</AppButton>
<AppButton
v-else
size="sm"
@click="connectTelegram"
>
{{ t('settings.securityConnected.telegram.connect') }}
</AppButton>
</template>
</SettingsRow>
</SettingsSectionCard>
<AppDialog
:visible="twoFactorDialogVisible"
@update:visible="twoFactorDialogVisible = $event"
:title="t('settings.securityConnected.twoFactorDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
</p>
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
:maxlength="6"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
{{ t('settings.securityConnected.twoFactorDialog.cancel') }}
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
</AppButton>
</div>
</template>
</AppDialog>
<AppDialog
:visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event"
:title="t('settings.securityConnected.changePassword.dialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
</p>
<div v-if="changePasswordError" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ changePasswordError }}
</div>
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
<AppInput
id="currentPassword"
v-model="currentPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
<AppInput
id="newPassword"
v-model="newPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
<AppInput
id="confirmPassword"
v-model="confirmPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="changePasswordLoading"
@click="changePasswordDialogVisible = false"
>
{{ t('settings.securityConnected.changePassword.dialog.cancel') }}
</AppButton>
<AppButton
size="sm"
:loading="changePasswordLoading"
@click="changePassword"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -1,185 +0,0 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
import { useTranslation } from 'i18next-vue';
const props = defineProps<{
dialogVisible: boolean;
error: string;
loading: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
emailConnected: boolean;
telegramConnected: boolean;
telegramUsername: string;
}>();
const emit = defineEmits<{
(e: 'update:dialogVisible', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'close'): void;
(e: 'change-password'): void;
(e: 'connect-telegram'): void;
(e: 'disconnect-telegram'): void;
}>();
const { t } = useTranslation();
const handleChangePassword = () => {
emit('change-password');
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h3 class="text-sm font-semibold text-foreground mb-3">{{ t('settings.connectedAccounts.title') }}</h3>
</div>
<div class="p-6 space-y-4">
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-info/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.connectedAccounts.email.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ emailConnected ? t('settings.connectedAccounts.email.connected') : t('settings.connectedAccounts.email.notConnected') }}
</p>
</div>
</div>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? t('settings.connectedAccounts.email.connected') : t('settings.connectedAccounts.email.disconnected') }}
</span>
</div>
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#0088cc]/10 flex items-center justify-center shrink-0">
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.connectedAccounts.telegram.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ telegramConnected ? (telegramUsername || t('settings.connectedAccounts.telegram.connectedFallback')) : t('settings.connectedAccounts.telegram.hint') }}
</p>
</div>
</div>
<AppButton
v-if="telegramConnected"
variant="danger"
size="sm"
@click="$emit('disconnect-telegram')"
>
{{ t('common.disconnect') }}
</AppButton>
<AppButton
v-else
size="sm"
@click="$emit('connect-telegram')"
>
{{ t('common.connect') }}
</AppButton>
</div>
</div>
<AppDialog
:visible="dialogVisible"
@update:visible="$emit('update:dialogVisible', $event)"
:title="t('settings.securityConnected.changePassword.dialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
</p>
<div v-if="error" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ error }}
</div>
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
<AppInput
id="currentPassword"
:model-value="currentPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
@update:model-value="$emit('update:currentPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
<AppInput
id="newPassword"
:model-value="newPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
@update:model-value="$emit('update:newPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
<AppInput
id="confirmPassword"
:model-value="confirmPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
@update:model-value="$emit('update:confirmPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="loading"
@click="$emit('close')"
>
<template #icon>
<XIcon class="w-4 h-4" />
</template>
{{ t('common.cancel') }}
</AppButton>
<AppButton
size="sm"
:loading="loading"
@click="handleChangePassword"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

@@ -1,147 +0,0 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { computed } from 'vue';
import { useTranslation } from 'i18next-vue';
import AppButton from '@/components/app/AppButton.vue';
import AppInput from '@/components/app/AppInput.vue';
import AppProgressBar from '@/components/app/AppProgressBar.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import MailIcon from '@/components/icons/MailIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue';
import UserIcon from '@/components/icons/UserIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const auth = useAuthStore();
const { t } = useTranslation();
const props = defineProps<{
editing: boolean;
username: string;
email: string;
saving: boolean;
}>();
const emit = defineEmits<{
(e: 'update:username', value: string): void;
(e: 'update:email', value: string): void;
(e: 'start-edit'): void;
(e: 'cancel-edit'): void;
(e: 'save'): void;
(e: 'change-password'): void;
}>();
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">{{ t('settings.profile.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
{{ t('settings.profile.subtitle') }}
</p>
</div>
<div class="p-6 space-y-6">
<div class="flex items-center gap-4 pb-4 border-b border-border">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || t('settings.profile.userFallback') }}</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
<div class="grid gap-6 max-w-2xl">
<div class="grid gap-2">
<label for="username" class="text-sm font-medium text-foreground">{{ t('settings.profile.username') }}</label>
<AppInput
id="username"
:model-value="username"
:readonly="!editing"
:inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
@update:model-value="emit('update:username', String($event))"
>
<template #prefix>
<UserIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="email" class="text-sm font-medium text-foreground">{{ t('settings.profile.email') }}</label>
<AppInput
id="email"
:model-value="email"
:readonly="!editing"
:inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
@update:model-value="emit('update:email', $event || '')"
>
<template #prefix>
<MailIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<div class="pt-4 border-t border-border">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" x2="12" y1="3" y2="15"/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-foreground">{{ t('settings.profile.storageUsage') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.profile.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) }) }}</p>
</div>
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
</div>
<AppProgressBar :value="storagePercentage" />
</div>
</div>
<div class="px-6 py-4 bg-muted/30 border-t border-border flex items-center gap-3">
<template v-if="editing">
<AppButton size="sm" :loading="saving" @click="emit('save')">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('common.save') }}
</AppButton>
<AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('cancel-edit')">
<template #icon>
<XIcon class="w-4 h-4" />
</template>
{{ t('common.cancel') }}
</AppButton>
</template>
<template v-else>
<AppButton size="sm" @click="emit('start-edit')">
<template #icon>
<PencilIcon class="w-4 h-4" />
</template>
{{ t('settings.profile.editProfile') }}
</AppButton>
<AppButton variant="secondary" size="sm" @click="emit('change-password')">
{{ t('settings.profile.changePassword') }}
</AppButton>
</template>
</div>
</div>
</template>

View File

@@ -1,173 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const props = defineProps<{
twoFactorEnabled: boolean;
changePasswordError: string;
changePasswordLoading: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}>();
const emit = defineEmits<{
(e: 'update:twoFactorEnabled', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'toggle-2fa'): void;
(e: 'change-password'): void;
(e: 'close-password-dialog'): void;
(e: 'close-2fa-dialog'): void;
(e: 'confirm-2fa'): void;
}>();
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
const { t } = useTranslation();
const handleToggle2FA = async () => {
if (!props.twoFactorEnabled) {
twoFactorDialogVisible.value = true;
} else {
emit('toggle-2fa');
}
};
const confirmTwoFactor = async () => {
emit('confirm-2fa');
twoFactorDialogVisible.value = false;
twoFactorCode.value = '';
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">{{ t('settings.securityConnected.header.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
{{ t('settings.securityConnected.header.subtitle') }}
</p>
</div>
<div class="p-6 space-y-4">
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.accountStatus.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.accountStatus.detail') }}</p>
</div>
</div>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
</div>
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<LockIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactor.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled') }}
</p>
</div>
</div>
<AppSwitch
:model-value="twoFactorEnabled"
@update:model-value="emit('update:twoFactorEnabled', $event)"
@change="handleToggle2FA"
/>
</div>
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ t('settings.securityConnected.changePassword.detail') }}
</p>
</div>
</div>
<AppButton size="sm" @click="$emit('change-password')">
{{ t('settings.securityConnected.changePassword.button') }}
</AppButton>
</div>
</div>
<AppDialog
:visible="twoFactorDialogVisible"
@update:visible="twoFactorDialogVisible = $event"
:title="t('settings.securityConnected.twoFactorDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
</p>
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
:maxlength="6"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
<template #icon>
<XIcon class="w-4 h-4" />
</template>
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import { cn } from '@/lib/utils';
import { computed, useAttrs } from 'vue';
defineOptions({ inheritAttrs: false });
type Tone = 'info' | 'warning';
const props = withDefaults(defineProps<{
tone?: Tone;
title?: string;
contentClass?: string;
titleClass?: string;
iconClass?: string;
}>(), {
tone: 'info',
title: '',
contentClass: 'text-xs text-foreground/70',
titleClass: 'font-medium text-foreground mb-1',
iconClass: '',
});
const attrs = useAttrs();
const rootClass = computed(() => cn(
'flex items-start gap-2 rounded-md border p-4',
props.tone === 'warning' ? 'border-warning/20 bg-warning/5' : 'border-info/20 bg-info/5',
));
const defaultIconClass = computed(() => cn(
'mt-0.5 h-4 w-4 shrink-0',
props.tone === 'warning' ? 'text-warning' : 'text-info',
props.iconClass,
));
</script>
<template>
<div v-bind="attrs" :class="rootClass">
<slot name="icon">
<component :is="tone === 'warning' ? AlertTriangleIcon : InfoIcon" :class="defaultIconClass" />
</slot>
<div :class="contentClass">
<p v-if="title" :class="titleClass">{{ title }}</p>
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed, useAttrs } from 'vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<{
title: string;
description?: string;
iconBoxClass?: string;
hoverClass?: string;
titleClass?: string;
descriptionClass?: string;
actionsClass?: string;
rowClass?: string;
}>(), {
description: '',
iconBoxClass: '',
hoverClass: 'hover:bg-muted/30',
titleClass: 'text-sm font-medium text-foreground',
descriptionClass: 'text-xs text-foreground/60 mt-0.5',
actionsClass: '',
rowClass: '',
});
const attrs = useAttrs();
const rootClass = computed(() => cn(
'flex items-center justify-between gap-4 px-6 py-4 transition-all',
props.hoverClass,
props.rowClass,
));
const iconClass = computed(() => cn(
'w-10 h-10 rounded-md flex items-center justify-center shrink-0',
props.iconBoxClass,
));
const actionsWrapperClass = computed(() => cn('shrink-0', props.actionsClass));
</script>
<template>
<div v-bind="attrs" :class="rootClass">
<div class="flex min-w-0 items-center gap-4">
<div :class="iconClass">
<slot name="icon" />
</div>
<div class="min-w-0">
<p :class="titleClass">{{ title }}</p>
<p v-if="description" :class="descriptionClass">{{ description }}</p>
</div>
</div>
<div v-if="$slots.actions" :class="actionsWrapperClass">
<slot name="actions" />
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed, useAttrs } from 'vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<{
title?: string;
description?: string;
bodyClass?: string;
headerClass?: string;
titleClass?: string;
descriptionClass?: string;
}>(), {
title: '',
description: '',
bodyClass: 'divide-y divide-border',
headerClass: '',
titleClass: 'text-base font-semibold text-foreground',
descriptionClass: 'text-sm text-foreground/60 mt-0.5',
});
const attrs = useAttrs();
const rootClass = computed(() => cn(
'bg-surface border border-border rounded-lg',
));
</script>
<template>
<div v-bind="attrs" :class="rootClass">
<div
v-if="title || description || $slots['header-actions']"
:class="cn(
'px-6 py-4 border-b border-border',
$slots['header-actions'] ? 'flex items-center justify-between gap-4' : '',
headerClass,
)"
>
<div class="min-w-0">
<h2 v-if="title" :class="titleClass">{{ title }}</h2>
<p v-if="description" :class="descriptionClass">{{ description }}</p>
</div>
<div v-if="$slots['header-actions']" class="shrink-0">
<slot name="header-actions" />
</div>
</div>
<div :class="bodyClass">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
type PaymentHistoryItem = {
id: string;
date: string;
amount: number;
plan: string;
status: string;
invoiceId: string;
};
defineProps<{
title: string;
description: string;
items: PaymentHistoryItem[];
formatMoney: (amount: number) => string;
getStatusStyles: (status: string) => string;
getStatusLabel: (status: string) => string;
dateLabel: string;
amountLabel: string;
planLabel: string;
statusLabel: string;
invoiceLabel: string;
emptyLabel: string;
downloadLabel: string;
}>();
const emit = defineEmits<{
(e: 'download', item: PaymentHistoryItem): void;
}>();
</script>
<template>
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<DownloadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ description }}</p>
</div>
</div>
<div class="border border-border rounded-lg overflow-hidden">
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
<div class="col-span-3">{{ dateLabel }}</div>
<div class="col-span-2">{{ amountLabel }}</div>
<div class="col-span-3">{{ planLabel }}</div>
<div class="col-span-2">{{ statusLabel }}</div>
<div class="col-span-2 text-right">{{ invoiceLabel }}</div>
</div>
<div v-if="items.length === 0" class="text-center py-12 text-foreground/60">
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<DownloadIcon class="w-8 h-8 text-foreground/40" />
</div>
<p>{{ emptyLabel }}</p>
</div>
<div
v-for="item in items"
:key="item.id"
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
>
<div class="col-span-3">
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-foreground">{{ formatMoney(item.amount) }}</p>
</div>
<div class="col-span-3">
<p class="text-sm text-foreground">{{ item.plan }}</p>
</div>
<div class="col-span-2">
<span :class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`">
{{ getStatusLabel(item.status) }}
</span>
</div>
<div class="col-span-2 flex justify-end">
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all"
@click="emit('download', item)"
>
<DownloadIcon class="w-4 h-4" />
<span>{{ downloadLabel }}</span>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { ModelPlan } from '@/api/client';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
defineProps<{
title: string;
description: string;
isLoading: boolean;
plans: ModelPlan[];
currentPlanId?: string;
subscribing: string | null;
formatMoney: (amount: number) => string;
getPlanStorageText: (plan: ModelPlan) => string;
getPlanDurationText: (plan: ModelPlan) => string;
getPlanUploadsText: (plan: ModelPlan) => string;
currentPlanLabel: string;
processingLabel: string;
upgradeLabel: string;
}>();
const emit = defineEmits<{
(e: 'subscribe', plan: ModelPlan): void;
}>();
</script>
<template>
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<CreditCardIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ description }}</p>
</div>
</div>
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i">
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="plan in plans"
:key="plan.id"
class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-all"
>
<div class="mb-3">
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
</div>
<div class="mb-4">
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
</div>
<ul class="space-y-2 mb-4 text-sm">
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ getPlanStorageText(plan) }}
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ getPlanDurationText(plan) }}
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ getPlanUploadsText(plan) }}
</li>
</ul>
<button
:disabled="!!subscribing || plan.id === currentPlanId"
:class="[
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all',
plan.id === currentPlanId
? 'bg-muted/50 text-foreground/60 cursor-not-allowed'
: subscribing === plan.id
? 'bg-muted/50 text-foreground/60 cursor-wait'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
]"
@click="emit('subscribe', plan)"
>
{{ plan.id === currentPlanId
? currentPlanLabel
: (subscribing === plan.id ? processingLabel : upgradeLabel) }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
defineProps<{
visible: boolean;
title: string;
subtitle: string;
presets: number[];
amount: number | null;
loading: boolean;
customAmountLabel: string;
amountPlaceholder: string;
hint: string;
cancelLabel: string;
proceedLabel: string;
formatMoney: (amount: number) => string;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'update:amount', value: number | null): void;
(e: 'selectPreset', amount: number): void;
(e: 'submit'): void;
}>();
</script>
<template>
<AppDialog
:visible="visible"
@update:visible="emit('update:visible', $event)"
:title="title"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ subtitle }}
</p>
<div class="grid grid-cols-4 gap-3">
<button
v-for="preset in presets"
:key="preset"
:class="[
'py-2 px-3 rounded-md text-sm font-medium transition-all',
amount === preset
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 text-foreground hover:bg-muted'
]"
@click="emit('selectPreset', preset)"
>
{{ formatMoney(preset) }}
</button>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">{{ customAmountLabel }}</label>
<div class="flex items-center gap-2">
<span class="text-lg font-semibold text-foreground">$</span>
<AppInput
:model-value="amount"
type="number"
:placeholder="amountPlaceholder"
inputClass="flex-1"
min="1"
step="1"
@update:model-value="emit('update:amount', typeof $event === 'number' || $event === null
? $event
: ($event === '' ? null : Number($event)))"
/>
</div>
</div>
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
<p>{{ hint }}</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton
variant="secondary"
size="sm"
:disabled="loading"
@click="emit('update:visible', false)"
>
{{ cancelLabel }}
</AppButton>
<AppButton
size="sm"
:loading="loading"
:disabled="!amount || amount < 1 || loading"
@click="emit('submit')"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ proceedLabel }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
import UploadIcon from '@/components/icons/UploadIcon.vue';
defineProps<{
storageTitle: string;
storageDescription: string;
storagePercentage: number;
uploadsTitle: string;
uploadsDescription: string;
uploadsPercentage: number;
}>();
</script>
<template>
<div>
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<ActivityIcon class="w-5 h-5 text-accent" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ storageTitle }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ storageDescription }}</p>
</div>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-primary h-full rounded-full transition-all duration-300"
:style="{ width: `${storagePercentage}%` }"
></div>
</div>
</div>
<div class="px-6 py-4 hover:bg-muted/30 transition-all border-t border-border">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<UploadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ uploadsTitle }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ uploadsDescription }}</p>
</div>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-info h-full rounded-full transition-all duration-300"
:style="{ width: `${uploadsPercentage}%` }"
></div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import SettingsRow from '../SettingsRow.vue';
defineProps<{
title: string;
description: string;
buttonLabel: string;
}>();
const emit = defineEmits<{
(e: 'topup'): void;
}>();
</script>
<template>
<SettingsRow :title="title" :description="description" iconBoxClass="bg-primary/10">
<template #icon>
<CoinsIcon class="w-5 h-5 text-primary" />
</template>
<template #actions>
<AppButton size="sm" @click="emit('topup')">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
{{ buttonLabel }}
</AppButton>
</template>
</SettingsRow>
</template>

View File

@@ -1,468 +0,0 @@
<script setup lang="ts">
import { client, type ModelPlan } from '@/api/client';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
import UploadIcon from '@/components/icons/UploadIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import { useTranslation } from 'i18next-vue';
import { computed, ref } from 'vue';
const toast = useAppToast();
const auth = useAuthStore();
const { t } = useTranslation();
const { data, isLoading } = useQuery({
key: () => ['payments-and-plans'],
query: () => client.plans.plansList(),
});
const subscribing = ref<string | null>(null);
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(0);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
const paymentHistory = ref([
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
]);
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const uploadsUsed = ref(12);
const uploadsLimit = ref(50);
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
const currentPlanId = computed(() => {
if (auth.user?.plan_id) return auth.user.plan_id;
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id;
return undefined;
});
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
);
const uploadsPercentage = computed(() =>
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100)
);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDuration = (seconds?: number) => {
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
return t('settings.billing.durationMinutes', { minutes: Math.floor(seconds / 60) });
};
const getStatusStyles = (status: string) => {
switch (status) {
case 'success':
return 'bg-success/10 text-success';
case 'failed':
return 'bg-danger/10 text-danger';
case 'pending':
return 'bg-warning/10 text-warning';
default:
return 'bg-info/10 text-info';
}
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
success: t('settings.billing.status.success'),
failed: t('settings.billing.status.failed'),
pending: t('settings.billing.status.pending'),
};
return map[status] || status;
};
const currencyFormatter = computed(() => new Intl.NumberFormat(getActiveI18n()?.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
}));
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
const subscribe = async (plan: ModelPlan) => {
if (!plan.id) return;
subscribing.value = plan.id;
try {
await client.payments.paymentsCreate({
amount: plan.price || 0,
plan_id: plan.id,
});
toast.add({
severity: 'success',
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
detail: t('settings.billing.toast.subscriptionSuccessDetail', { plan: plan.name || '' }),
life: 3000,
});
paymentHistory.value.unshift({
id: `inv_${Date.now()}`,
date: new Date().toLocaleDateString(getActiveI18n()?.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
amount: plan.price || 0,
plan: plan.name || t('settings.billing.unknownPlan'),
status: 'success',
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`,
});
} catch (err: any) {
console.error(err);
toast.add({
severity: 'error',
summary: t('settings.billing.toast.subscriptionFailedSummary'),
detail: err.message || t('settings.billing.toast.subscriptionFailedDetail'),
life: 5000,
});
} finally {
subscribing.value = null;
}
};
const handleTopup = async (amount: number) => {
topupLoading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 1500));
toast.add({
severity: 'success',
summary: t('settings.billing.toast.topupSuccessSummary'),
detail: t('settings.billing.toast.topupSuccessDetail', { amount: formatMoney(amount) }),
life: 3000,
});
topupDialogVisible.value = false;
topupAmount.value = null;
} catch (e: any) {
toast.add({
severity: 'error',
summary: t('settings.billing.toast.topupFailedSummary'),
detail: e.message || t('settings.billing.toast.topupFailedDetail'),
life: 5000,
});
} finally {
topupLoading.value = false;
}
};
const handleDownloadInvoice = (item: typeof paymentHistory.value[number]) => {
toast.add({
severity: 'info',
summary: t('settings.billing.toast.downloadingSummary'),
detail: t('settings.billing.toast.downloadingDetail', { invoiceId: item.invoiceId }),
life: 2000,
});
setTimeout(() => {
toast.add({
severity: 'success',
summary: t('settings.billing.toast.downloadedSummary'),
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
life: 3000,
});
}, 1500);
};
const openTopupDialog = () => {
topupAmount.value = null;
topupDialogVisible.value = true;
};
const selectPreset = (amount: number) => {
topupAmount.value = amount;
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.billing.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
{{ t('settings.content.billing.subtitle') }}
</p>
</div>
<div class="divide-y divide-border">
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<CoinsIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.walletBalance') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ t('settings.billing.currentBalance', { balance: formatMoney(walletBalance) }) }}
</p>
</div>
</div>
<AppButton size="sm" @click="openTopupDialog">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
{{ t('settings.billing.topUp') }}
</AppButton>
</div>
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<CreditCardIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.availablePlans') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ t('settings.billing.availablePlansHint') }}
</p>
</div>
</div>
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i">
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="plan in data?.data?.data.plans || []"
:key="plan.id"
class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-all"
>
<div class="mb-3">
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
</div>
<div class="mb-4">
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
</div>
<ul class="space-y-2 mb-4 text-sm">
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) }) }}
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) }) }}
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ t('settings.billing.planUploads', { count: plan.upload_limit }) }}
</li>
</ul>
<button
:disabled="!!subscribing || plan.id === currentPlanId"
:class="[
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all',
plan.id === currentPlanId
? 'bg-muted/50 text-foreground/60 cursor-not-allowed'
: subscribing === plan.id
? 'bg-muted/50 text-foreground/60 cursor-wait'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
]"
@click="subscribe(plan)"
>
{{ plan.id === currentPlanId
? t('settings.billing.currentPlan')
: (subscribing === plan.id ? t('settings.billing.processing') : t('settings.billing.upgrade')) }}
</button>
</div>
</div>
</div>
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<ActivityIcon class="w-5 h-5 text-accent" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.storage') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ t('settings.billing.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) }) }}
</p>
</div>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-primary h-full rounded-full transition-all duration-300"
:style="{ width: `${storagePercentage}%` }"
></div>
</div>
</div>
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<UploadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.monthlyUploads') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ t('settings.billing.uploadsUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit }) }}
</p>
</div>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-info h-full rounded-full transition-all duration-300"
:style="{ width: `${uploadsPercentage}%` }"
></div>
</div>
</div>
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<DownloadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentHistory') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ t('settings.billing.paymentHistorySubtitle') }}
</p>
</div>
</div>
<div class="border border-border rounded-lg overflow-hidden">
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
<div class="col-span-3">{{ t('settings.billing.table.date') }}</div>
<div class="col-span-2">{{ t('settings.billing.table.amount') }}</div>
<div class="col-span-3">{{ t('settings.billing.table.plan') }}</div>
<div class="col-span-2">{{ t('settings.billing.table.status') }}</div>
<div class="col-span-2 text-right">{{ t('settings.billing.table.invoice') }}</div>
</div>
<div v-if="paymentHistory.length === 0" class="text-center py-12 text-foreground/60">
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<DownloadIcon class="w-8 h-8 text-foreground/40" />
</div>
<p>{{ t('settings.billing.noPaymentHistory') }}</p>
</div>
<div
v-for="item in paymentHistory"
:key="item.id"
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
>
<div class="col-span-3">
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-foreground">{{ formatMoney(item.amount) }}</p>
</div>
<div class="col-span-3">
<p class="text-sm text-foreground">{{ item.plan }}</p>
</div>
<div class="col-span-2">
<span
:class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`"
>
{{ getStatusLabel(item.status) }}
</span>
</div>
<div class="col-span-2 flex justify-end">
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all"
@click="handleDownloadInvoice(item)"
>
<DownloadIcon class="w-4 h-4" />
<span>{{ t('settings.billing.download') }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
<AppDialog
:visible="topupDialogVisible"
@update:visible="topupDialogVisible = $event"
:title="t('settings.billing.topupDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.billing.topupDialog.subtitle') }}
</p>
<div class="grid grid-cols-4 gap-3">
<button
v-for="preset in topupPresets"
:key="preset"
:class="[
'py-2 px-3 rounded-md text-sm font-medium transition-all',
topupAmount === preset
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 text-foreground hover:bg-muted'
]"
@click="selectPreset(preset)"
>
{{ formatMoney(preset) }}
</button>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">{{ t('settings.billing.topupDialog.customAmount') }}</label>
<div class="flex items-center gap-2">
<span class="text-lg font-semibold text-foreground">$</span>
<AppInput
v-model.number="topupAmount"
type="number"
:placeholder="t('settings.billing.topupDialog.enterAmount')"
inputClass="flex-1"
min="1"
step="1"
/>
</div>
</div>
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
<p>{{ t('settings.billing.topupDialog.hint') }}</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton
variant="secondary"
size="sm"
:disabled="topupLoading"
@click="topupDialogVisible = false"
>
{{ t('common.cancel') }}
</AppButton>
<AppButton
size="sm"
:loading="topupLoading"
:disabled="!topupAmount || topupAmount < 1 || topupLoading"
@click="handleTopup(topupAmount || 0)"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.billing.topupDialog.proceed') }}
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

@@ -1,117 +0,0 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { useTranslation } from 'i18next-vue';
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
const handleDeleteAccount = () => {
confirm.require({
message: t('settings.dangerZone.confirm.deleteAccountMessage'),
header: t('settings.dangerZone.confirm.deleteAccountHeader'),
acceptLabel: t('settings.dangerZone.confirm.deleteAccountAccept'),
rejectLabel: t('settings.dangerZone.confirm.deleteAccountReject'),
accept: () => {
toast.add({
severity: 'info',
summary: t('settings.dangerZone.toast.deleteAccountSummary'),
detail: t('settings.dangerZone.toast.deleteAccountDetail'),
life: 5000,
});
},
});
};
const handleClearData = () => {
confirm.require({
message: t('settings.dangerZone.confirm.clearDataMessage'),
header: t('settings.dangerZone.confirm.clearDataHeader'),
acceptLabel: t('settings.dangerZone.confirm.clearDataAccept'),
rejectLabel: t('settings.dangerZone.confirm.clearDataReject'),
accept: () => {
toast.add({
severity: 'info',
summary: t('settings.dangerZone.toast.clearDataSummary'),
detail: t('settings.dangerZone.toast.clearDataDetail'),
life: 5000,
});
},
});
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-danger">{{ t('settings.content.danger.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
{{ t('settings.content.danger.subtitle') }}
</p>
</div>
<div class="divide-y divide-border">
<div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<AlertTriangleIcon class="w-5 h-5 text-danger" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.dangerZone.deleteAccount.title') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ t('settings.dangerZone.deleteAccount.description') }}
</p>
</div>
</div>
<AppButton variant="danger" size="sm" @click="handleDeleteAccount">
<template #icon>
<TrashIcon class="w-4 h-4" />
</template>
{{ t('settings.dangerZone.deleteAccount.button') }}
</AppButton>
</div>
<div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.dangerZone.clearData.title') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ t('settings.dangerZone.clearData.description') }}
</p>
</div>
</div>
<AppButton variant="danger" size="sm" @click="handleClearData">
<template #icon>
<SlidersIcon class="w-4 h-4" />
</template>
{{ t('settings.dangerZone.clearData.button') }}
</AppButton>
</div>
</div>
<div class="mx-6 my-4 border border-warning/30 bg-warning/5 rounded-md p-4">
<div class="flex items-start gap-2">
<InfoIcon class="w-4 h-4 text-warning mt-0.5" />
<div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">{{ t('settings.dangerZone.warning.title') }}</p>
<p>
{{ t('settings.dangerZone.warning.description') }}
</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,550 +0,0 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { supportedLocales } from '@/i18n/constants';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
import { computed, ref, watch } from 'vue';
const auth = useAuthStore();
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
const languageSaving = ref(false);
const languageOptions = computed(() => supportedLocales.map((value) => ({
value,
label: t(`settings.securityConnected.language.options.${value}`)
})));
watch(() => auth.user, (nextUser) => {
// selectedLanguage.value = normalizeLocale((nextUser as any)?.language ?? (nextUser as any)?.locale);
}, { deep: true });
// 2FA state
const twoFactorEnabled = ref(false);
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
// Connected accounts state
const emailConnected = ref(true);
const telegramConnected = ref(false);
const telegramUsername = ref('');
// Change password state
const changePasswordDialogVisible = ref(false);
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const changePasswordLoading = ref(false);
const changePasswordError = ref('');
// Change password handler
const openChangePassword = () => {
changePasswordDialogVisible.value = true;
changePasswordError.value = '';
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
};
const changePassword = async () => {
changePasswordError.value = '';
if (newPassword.value !== confirmPassword.value) {
changePasswordError.value = t('settings.securityConnected.changePassword.dialog.errors.mismatch');
return;
}
if (newPassword.value.length < 6) {
changePasswordError.value = t('settings.securityConnected.changePassword.dialog.errors.minLength');
return;
}
changePasswordLoading.value = true;
try {
await auth.changePassword(currentPassword.value, newPassword.value);
changePasswordDialogVisible.value = false;
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
toast.add({
severity: 'success',
summary: t('settings.securityConnected.changePassword.toast.successSummary'),
detail: t('settings.securityConnected.changePassword.toast.successDetail'),
life: 3000
});
} catch (e: any) {
changePasswordError.value = e.message || t('settings.securityConnected.changePassword.dialog.errors.default');
} finally {
changePasswordLoading.value = false;
}
};
const handleLogout = () => {
confirm.require({
message: t('settings.securityConnected.logout.confirm.message'),
header: t('settings.securityConnected.logout.confirm.header'),
acceptLabel: t('settings.securityConnected.logout.confirm.accept'),
rejectLabel: t('settings.securityConnected.logout.confirm.reject'),
accept: async () => {
await auth.logout();
}
});
};
const saveLanguage = async () => {
languageSaving.value = true;
try {
const result = await auth.setLanguage(selectedLanguage.value);
if (result.ok && !result.fallbackOnly) {
toast.add({
severity: 'success',
summary: t('settings.securityConnected.language.toast.successSummary'),
detail: t('settings.securityConnected.language.toast.successDetail'),
life: 3000,
});
return;
}
toast.add({
severity: 'warn',
summary: t('settings.securityConnected.language.toast.errorSummary'),
detail: t('settings.securityConnected.language.toast.errorDetail'),
life: 5000,
});
} finally {
languageSaving.value = false;
}
};
// Toggle 2FA
const handleToggle2FA = async () => {
if (!twoFactorEnabled.value) {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorDialogVisible.value = true;
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorEnableFailedSummary'),
detail: t('settings.securityConnected.toast.twoFactorEnableFailedDetail'),
life: 5000
});
twoFactorEnabled.value = false;
}
} else {
try {
await new Promise(resolve => setTimeout(resolve, 500));
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.twoFactorDisabledSummary'),
detail: t('settings.securityConnected.toast.twoFactorDisabledDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorDisableFailedSummary'),
detail: t('settings.securityConnected.toast.twoFactorDisableFailedDetail'),
life: 5000
});
twoFactorEnabled.value = true;
}
}
};
// Confirm 2FA setup
const confirmTwoFactor = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorEnabled.value = true;
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.twoFactorEnabledSummary'),
detail: t('settings.securityConnected.toast.twoFactorEnabledDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorInvalidCodeSummary'),
detail: t('settings.securityConnected.toast.twoFactorInvalidCodeDetail'),
life: 5000
});
}
};
// Connect Telegram
const connectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
telegramConnected.value = true;
telegramUsername.value = '@telegram_user';
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.telegramConnectedSummary'),
detail: t('settings.securityConnected.toast.telegramConnectedDetail', { username: telegramUsername.value }),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.telegramConnectFailedSummary'),
detail: t('settings.securityConnected.toast.telegramConnectFailedDetail'),
life: 5000
});
}
};
// Disconnect Telegram
const disconnectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
telegramConnected.value = false;
telegramUsername.value = '';
toast.add({
severity: 'info',
summary: t('settings.securityConnected.toast.telegramDisconnectedSummary'),
detail: t('settings.securityConnected.toast.telegramDisconnectedDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.telegramDisconnectFailedSummary'),
detail: t('settings.securityConnected.toast.telegramDisconnectFailedDetail'),
life: 5000
});
}
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">{{ t('settings.securityConnected.header.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
{{ t('settings.securityConnected.header.subtitle') }}
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<!-- Account Status -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-success/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.accountStatus.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.accountStatus.detail') }}</p>
</div>
</div>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
</div>
<!-- Language -->
<div class="flex items-center justify-between gap-4 px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15 15 0 0 1 0 20" />
<path d="M12 2a15 15 0 0 0 0 20" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.language.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.language.detail') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<select
v-model="selectedLanguage"
:disabled="languageSaving"
class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground disabled:opacity-60"
>
<option
v-for="option in languageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<AppButton
size="sm"
:loading="languageSaving"
:disabled="languageSaving"
@click="saveLanguage"
>
{{ t('settings.securityConnected.language.save') }}
</AppButton>
</div>
</div>
<!-- Two-Factor Authentication -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<LockIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactor.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled') }}
</p>
</div>
</div>
<AppSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" />
</div>
<!-- Change Password -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.changePassword.detail') }}</p>
</div>
</div>
<AppButton size="sm" @click="openChangePassword">
{{ t('settings.securityConnected.changePassword.button') }}
</AppButton>
</div>
<!-- Logout -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<XCircleIcon class="w-5 h-5 text-danger" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.logout.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.logout.detail') }}</p>
</div>
</div>
<AppButton variant="danger" size="sm" @click="handleLogout">
<template #icon>
<XCircleIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.logout.button') }}
</AppButton>
</div>
<!-- Email Connection -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.email.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected') }}
</p>
</div>
</div>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? t('settings.securityConnected.email.badgeConnected') : t('settings.securityConnected.email.badgeDisconnected') }}
</span>
</div>
<!-- Telegram Connection -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-[#0088cc]/10 flex items-center justify-center shrink-0">
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.telegram.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ telegramConnected ? (telegramUsername || t('settings.securityConnected.telegram.connectedFallback')) : t('settings.securityConnected.telegram.detailDisconnected') }}
</p>
</div>
</div>
<AppButton
v-if="telegramConnected"
variant="danger"
size="sm"
@click="disconnectTelegram"
>
{{ t('settings.securityConnected.telegram.disconnect') }}
</AppButton>
<AppButton
v-else
size="sm"
@click="connectTelegram"
>
{{ t('settings.securityConnected.telegram.connect') }}
</AppButton>
</div>
</div>
<!-- 2FA Setup Dialog -->
<AppDialog
:visible="twoFactorDialogVisible"
@update:visible="twoFactorDialogVisible = $event"
:title="t('settings.securityConnected.twoFactorDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
</p>
<!-- QR Code Placeholder -->
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<!-- Secret Key -->
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<!-- Verification Code Input -->
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
:maxlength="6"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
{{ t('settings.securityConnected.twoFactorDialog.cancel') }}
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
</AppButton>
</div>
</template>
</AppDialog>
<!-- Change Password Dialog -->
<AppDialog
:visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event"
:title="t('settings.securityConnected.changePassword.dialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
</p>
<!-- Error Message -->
<div v-if="changePasswordError" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ changePasswordError }}
</div>
<!-- Current Password -->
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
<AppInput
id="currentPassword"
v-model="currentPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<!-- New Password -->
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
<AppInput
id="newPassword"
v-model="newPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
<AppInput
id="confirmPassword"
v-model="confirmPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="changePasswordLoading"
@click="changePasswordDialogVisible = false"
>
{{ t('settings.securityConnected.changePassword.dialog.cancel') }}
</AppButton>
<AppButton
size="sm"
:loading="changePasswordLoading"
@click="changePassword"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

@@ -3,7 +3,6 @@ import type { ModelVideo } from '@/api/client';
import { formatBytes, getStatusSeverity } from '@/lib/utils'; import { formatBytes, getStatusSeverity } from '@/lib/utils';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
import { computed } from 'vue'; import { computed } from 'vue';
// import { getActiveI18n } from '@/i18n';
const props = defineProps<{ const props = defineProps<{
video: ModelVideo; video: ModelVideo;
@@ -15,7 +14,7 @@ const emit = defineEmits<{
delete: []; delete: [];
}>(); }>();
const { t } = useTranslation(); const { t, i18next } = useTranslation();
const formatFileSize = (bytes?: number): string => { const formatFileSize = (bytes?: number): string => {
if (!bytes) return '-'; if (!bytes) return '-';
@@ -36,7 +35,7 @@ const formatDuration = (seconds?: number): string => {
const formatDate = (dateStr?: string): string => { const formatDate = (dateStr?: string): string => {
if (!dateStr) return '-'; if (!dateStr) return '-';
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleString(getActiveI18n()?.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US', { return date.toLocaleString(i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US', {
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View File

@@ -1,4 +1,4 @@
import { baseAPIURL } from '@/api/httpClientAdapter.server'; import { customFetch } from '@httpClientAdapter';
import type { Context, Next } from 'hono'; import type { Context, Next } from 'hono';
export async function apiProxyMiddleware(c: Context, next: Next) { export async function apiProxyMiddleware(c: Context, next: Next) {
@@ -7,23 +7,5 @@ export async function apiProxyMiddleware(c: Context, next: Next) {
if (path !== '/r' && !path.startsWith('/r/')) { if (path !== '/r' && !path.startsWith('/r/')) {
return await next(); return await next();
} }
return customFetch(c.req.url, c.req)
const url = new URL(c.req.url);
url.host = baseAPIURL.replace(/^https?:\/\//, '');
url.protocol = 'https:';
url.pathname = path.replace(/^\/r/, '') || '/';
url.port = '';
const headers = new Headers(c.req.header());
headers.delete("host");
headers.delete("connection");
return fetch(url.toString(), {
method: c.req.method,
headers: headers,
body: c.req.raw.body,
// @ts-ignore
duplex: 'half',
credentials: 'include'
});
} }

View File

@@ -10,17 +10,17 @@ export function setupMiddlewares(app: Hono) {
fallbackLanguage: 'en', fallbackLanguage: 'en',
lookupCookie: 'i18next', lookupCookie: 'i18next',
lookupFromHeaderKey: 'accept-language', lookupFromHeaderKey: 'accept-language',
order: ['cookie', 'header'], order: ['cookie', 'header'],
}) ,contextStorage()); }) ,contextStorage());
app.use(cors(), async (c, next) => { app.use(cors(), async (c, next) => {
c.set("fetch", app.request.bind(app)); c.set("fetch", app.request.bind(app));
const ua = c.req.header("User-Agent"); const ua = c.req.header("User-Agent");
if (!ua) { if (!ua) {
return c.json({ error: "User-Agent header is missing" }, 400); return c.json({ error: "User-Agent header is missing" }, 400);
} }
c.set("isMobile", isMobile({ ua })); c.set("isMobile", isMobile({ ua }));
await next(); await next();
}); });

View File

@@ -1,11 +1,38 @@
import { client, ResponseResponse, type ModelUser } from '@/api/client'; import { client, type ModelUser, type ResponseResponse } from '@/api/client';
import { TinyMqttClient } from '@/lib/liteMqtt'; import { TinyMqttClient } from '@/lib/liteMqtt';
import { useTranslation } from 'i18next-vue'; import { useTranslation } from 'i18next-vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
type ProfileUpdatePayload = { username?: string; email?: string; language?: string; locale?: string }; type ProfileUpdatePayload = {
username?: string;
email?: string;
language?: string;
locale?: string;
};
type AuthResponseBody = ResponseResponse & {
data?: ModelUser | { user?: ModelUser };
};
const mqttBrokerUrl = 'wss://mqtt-dashboard.com:8884/mqtt';
const extractUser = (body?: AuthResponseBody | null): ModelUser | null => {
const data = body?.data;
if (!data) return null;
if (typeof data === 'object' && 'user' in data && data.user) {
return data.user;
}
return data as ModelUser;
};
const getGoogleLoginPath = () => {
const basePath = client.baseUrl.startsWith('/') ? client.baseUrl : `/${client.baseUrl}`;
return `${basePath}/auth/google/login`;
};
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<ModelUser | null>(null); const user = ref<ModelUser | null>(null);
@@ -15,94 +42,74 @@ export const useAuthStore = defineStore('auth', () => {
const error = ref<string | null>(null); const error = ref<string | null>(null);
const initialized = ref(false); const initialized = ref(false);
watch(user, (newUser) => { let mqttClient: TinyMqttClient | undefined;
if (import.meta.env.SSR) return;
let client: TinyMqttClient | undefined; const clearMqttClient = () => {
if (newUser?.id) { mqttClient?.disconnect();
client = new TinyMqttClient( mqttClient = undefined;
// 'wss://broker.emqx.io:8084/mqtt', };
'wss://mqtt-dashboard.com:8884/mqtt',
[['ecos1231231',newUser.id,'#'].join("/")], const clearState = () => {
(topic, msg) => console.log(`Tín hiệu nhận được [${topic}]:`, msg) user.value = null;
); loading.value = false;
client.connect(); error.value = null;
// client.auth.clearToken(); initialized.value = false;
} };
else {
if(client?.disconnect) client.disconnect(); watch(() => user.value?.id, (userId) => {
client = undefined; if (import.meta.env.SSR) return;
}
}, { deep: true }); clearMqttClient();
if (!userId) return;
mqttClient = new TinyMqttClient(
mqttBrokerUrl,
[['ecos1231231', userId, '#'].join('/')],
(topic, message) => {
console.log(`Tín hiệu nhận được [${topic}]:`, message);
}
);
mqttClient.connect();
});
watch(user, (newUser) => {
if (import.meta.env.SSR || !initialized.value || !newUser) return;
// const locale = getUserPreferredLocale(newUser);
// const activeLocale = getActiveI18n()?.resolvedLanguage;
// if (!locale || (activeLocale && normalizeLocale(activeLocale) === locale)) return;
// applyRuntimeLocale(locale);
// writeLocaleCookie(locale);
}, { deep: true });
// Initial check for session could go here if there was a /me endpoint or token check
async function init() { async function init() {
if (initialized.value) return; if (initialized.value) return;
await client.request({
path: '/me',
method: 'GET',
format: "json",
}).then(r => r.json()).then(r => {
if (r.data) {
user.value = r.data.user as ModelUser;
// const profileLocale = getUserPreferredLocale(user.value);
// const activeLocale = getActiveI18n()?.resolvedLanguage;
// if (profileLocale && (!activeLocale || normalizeLocale(activeLocale) !== profileLocale)) {
// applyRuntimeLocale(profileLocale);
// writeLocaleCookie(profileLocale);
// }
}
}).catch(() => { }).finally(() => {
initialized.value = true;
});
// client.request<
// ResponseResponse & {
// data?: ModelUser;
// },
// ResponseResponse
// >({
// path: '/me',
// method: 'GET'
// }).then(console.log)
// .finally(() => {
// initialized.value = true;
// });
}
async function login(username: string, password: string) {
loading.value = true;
error.value = null;
try { try {
const response = await client.auth.loginCreate({ const response = await client.request<AuthResponseBody, ResponseResponse>({
email: username, path: '/me',
password: password method: 'GET',
format: 'json',
}); });
// Expected response structure: { data: { code: 200, data: User, message: "..." } } based on typical wrapper + schema const nextUser = extractUser(response.data as AuthResponseBody);
// BUT client.ts generated code typically returns the body directly in .data property of HttpResponse if (nextUser) {
// And schema says ResponseResponse has 'data': {} user.value = nextUser;
// So: response.data (HttpResponse body) -> .data (ResponseResponse payload) }
} catch {
user.value = null;
} finally {
initialized.value = true;
}
}
const body = response.data as any; // Cast to access potential 'data' property if types are loose async function login(email: string, password: string) {
console.log("body", body); loading.value = true;
if (body && body.data) { error.value = null;
user.value = body.data.user;
// const profileLocale = getUserPreferredLocale(user.value); try {
// if (profileLocale) { const response = await client.auth.loginCreate({
// applyRuntimeLocale(profileLocale); email,
// writeLocaleCookie(profileLocale); password,
// } });
router.push('/'); const nextUser = extractUser(response.data as AuthResponseBody);
} else {
if (!nextUser) {
throw new Error(t('auth.errors.loginNoUserData')); throw new Error(t('auth.errors.loginNoUserData'));
} }
user.value = nextUser;
await router.push('/');
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
error.value = t('auth.errors.loginFailed', { error: e.message || t('auth.errors.unknown') }); error.value = t('auth.errors.loginFailed', { error: e.message || t('auth.errors.unknown') });
@@ -112,32 +119,22 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
async function loginWithGoogle() { function loginWithGoogle() {
// usually this initiates a redirect loop. if (typeof window === 'undefined') return;
// Doing it via client.request might follow redirect or return html. window.location.assign(getGoogleLoginPath());
// Best to just redirect the window.
window.location.href = `${client.baseUrl}/auth/google/login`;
} }
async function register(username: string, email: string, password: string) { async function register(username: string, email: string, password: string) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await client.auth.registerCreate({ await client.auth.registerCreate({
username, username,
email, email,
password password,
}); });
await router.push('/login');
// Check success
const body = response.data as any;
if (response.ok) {
// Auto login or redirect to login?
// Usually register returns success, user must login.
router.push('/login');
} else {
throw new Error(body.message || t('auth.errors.registrationFailedFallback'));
}
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
error.value = t('auth.errors.registrationFailed', { error: e.message || t('auth.errors.unknown') }); error.value = t('auth.errors.registrationFailed', { error: e.message || t('auth.errors.unknown') });
@@ -150,21 +147,20 @@ export const useAuthStore = defineStore('auth', () => {
async function updateProfile(data: ProfileUpdatePayload) { async function updateProfile(data: ProfileUpdatePayload) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await client.request< const response = await client.request<AuthResponseBody, ResponseResponse>({
ResponseResponse & { data?: ModelUser },
ResponseResponse
>({
path: '/me', path: '/me',
method: 'PUT', method: 'PUT',
body: data, body: data,
format: 'json' format: 'json',
}); });
const nextUser = extractUser(response.data as AuthResponseBody);
const body = response.data as any; if (nextUser) {
if (body && body.data) { user.value = { ...(user.value ?? {}), ...nextUser } as ModelUser;
user.value = { ...(user.value ?? {}), ...body.data } as ModelUser;
} }
return true; return true;
} catch (e: any) { } catch (e: any) {
console.error('Update profile error', e); console.error('Update profile error', e);
@@ -176,34 +172,14 @@ export const useAuthStore = defineStore('auth', () => {
} }
async function setLanguage(locale: string) { async function setLanguage(locale: string) {
// const normalizedLocale = normalizeLocale(locale);
// const previousLocale = resolveUserLocale(user.value);
// applyRuntimeLocale(normalizedLocale);
// writeLocaleCookie(normalizedLocale);
const previousUser = user.value ? { ...user.value } : null;
if (user.value) {
user.value = {
...user.value,
// language: normalizedLocale,
// locale: normalizedLocale,
} as ModelUser;
}
if (!user.value?.id) { if (!user.value?.id) {
return { ok: true as const, fallbackOnly: true as const }; return { ok: true as const, fallbackOnly: true as const };
} }
try { try {
// await updateProfile({ language: normalizedLocale, locale: normalizedLocale }); await updateProfile({ language: locale, locale });
return { ok: true as const, fallbackOnly: false as const }; return { ok: true as const, fallbackOnly: false as const };
} catch (e) { } catch (e) {
// applyRuntimeLocale(previousLocale);
if (previousUser) {
user.value = previousUser as ModelUser;
}
// writeLocaleCookie(normalizedLocale);
return { ok: false as const, fallbackOnly: true as const, error: e }; return { ok: false as const, fallbackOnly: true as const, error: e };
} }
} }
@@ -211,15 +187,16 @@ export const useAuthStore = defineStore('auth', () => {
async function changePassword(currentPassword: string, newPassword: string) { async function changePassword(currentPassword: string, newPassword: string) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
await client.request<ResponseResponse, ResponseResponse>({ await client.request<ResponseResponse, ResponseResponse>({
path: '/auth/change-password', path: '/auth/change-password',
method: 'POST', method: 'POST',
body: { body: {
current_password: currentPassword, current_password: currentPassword,
new_password: newPassword new_password: newPassword,
}, },
format: 'json' format: 'json',
}); });
return true; return true;
} catch (e: any) { } catch (e: any) {
@@ -231,6 +208,22 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
async function logout() {
loading.value = true;
error.value = null;
try {
await client.auth.logoutCreate();
} catch (e) {
console.error('Logout error', e);
} finally {
clearMqttClient();
user.value = null;
loading.value = false;
await router.push('/login');
}
}
return { return {
user, user,
loading, loading,
@@ -243,31 +236,10 @@ export const useAuthStore = defineStore('auth', () => {
updateProfile, updateProfile,
changePassword, changePassword,
setLanguage, setLanguage,
logout: async () => { logout,
loading.value = true;
// const activeLocale = getActiveI18n()?.resolvedLanguage;
// const localeBeforeLogout = typeof activeLocale === 'string' ? normalizeLocale(activeLocale) : undefined;
try {
await client.auth.logoutCreate();
user.value = null;
router.push('/login');
} catch (e: any) {
console.error('Logout error', e);
user.value = null;
router.push('/login');
} finally {
// if (localeBeforeLogout) {
// writeLocaleCookie(localeBeforeLogout);
// applyRuntimeLocale(localeBeforeLogout);
// }
loading.value = false;
}
},
$reset: () => { $reset: () => {
user.value = null; clearMqttClient();
loading.value = false; clearState();
error.value = null; },
initialized.value = false;
}
}; };
}); });