feat: add install guide share tokens and animations
This commit is contained in:
@@ -32,12 +32,14 @@ async def subscription_guides_route(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
async def public_subscription_guides_route(request: web.Request) -> web.Response:
|
||||
short_uuid = _normalize_short_uuid(request.match_info.get("short_uuid"))
|
||||
if not short_uuid:
|
||||
return web.json_response({"ok": False, "error": "invalid_short_uuid"}, status=404)
|
||||
share_token = subscription_dal.normalize_install_share_token(
|
||||
request.match_info.get("share_token")
|
||||
)
|
||||
if not share_token:
|
||||
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
|
||||
|
||||
status = await _subscription_guides_status_shared(request.app)
|
||||
subscription = await _public_subscription_payload(request, short_uuid)
|
||||
subscription = await _public_subscription_payload(request, share_token)
|
||||
payload = {
|
||||
"enabled": bool(status.get("enabled")),
|
||||
"config": status.get("config") if status.get("enabled") else None,
|
||||
@@ -160,19 +162,19 @@ async def _default_panel_subscription_page_config_uuid(panel_service: Any) -> st
|
||||
|
||||
async def _public_subscription_payload(
|
||||
request: web.Request,
|
||||
short_uuid: str,
|
||||
share_token: str,
|
||||
) -> Dict[str, Any]:
|
||||
settings: Settings = request.app["settings"]
|
||||
panel_service = _panel_service_from_app(request.app)
|
||||
raw_link = ""
|
||||
username = ""
|
||||
resolved_short_uuid = short_uuid
|
||||
resolved_short_uuid = ""
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
local_sub = await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
||||
local_sub = await subscription_dal.get_subscription_by_install_share_token(
|
||||
session,
|
||||
short_uuid,
|
||||
share_token,
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -185,16 +187,17 @@ async def _public_subscription_payload(
|
||||
if panel_user:
|
||||
raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
|
||||
username = str(panel_user.get("username") or "").strip()
|
||||
resolved_short_uuid = str(panel_user.get("shortUuid") or short_uuid).strip()
|
||||
resolved_short_uuid = str(panel_user.get("shortUuid") or "").strip()
|
||||
|
||||
display_link, connect_url = await prepare_config_links(settings, raw_link)
|
||||
return {
|
||||
"active": bool(display_link),
|
||||
"config_link": display_link,
|
||||
"connect_url": connect_url or display_link,
|
||||
"panel_short_uuid": resolved_short_uuid,
|
||||
"panel_short_uuid": resolved_short_uuid or None,
|
||||
"install_share_token": share_token,
|
||||
"username": username,
|
||||
"share_url": _public_install_url(request, resolved_short_uuid),
|
||||
"share_url": _public_install_url(request, share_token),
|
||||
}
|
||||
|
||||
|
||||
@@ -231,14 +234,7 @@ def _local_subscription_is_publicly_active(subscription: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_short_uuid(value: Any) -> str:
|
||||
short_uuid = str(value or "").strip()
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{8,128}", short_uuid):
|
||||
return ""
|
||||
return short_uuid
|
||||
|
||||
|
||||
def _public_install_url(request: web.Request, short_uuid: str) -> str:
|
||||
def _public_install_url(request: web.Request, share_token: str) -> str:
|
||||
settings: Settings = request.app["settings"]
|
||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if configured_base:
|
||||
@@ -255,7 +251,7 @@ def _public_install_url(request: web.Request, short_uuid: str) -> str:
|
||||
)
|
||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
base = f"{proto}://{host}"
|
||||
return f"{base.rstrip('/')}/install/share/{quote(short_uuid)}"
|
||||
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||
|
||||
|
||||
def _subscription_page_request_headers(request: web.Request) -> Dict[str, str]:
|
||||
|
||||
@@ -7,7 +7,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/login/password", index_route)
|
||||
app.router.add_get("/home", index_route)
|
||||
app.router.add_get("/install", index_route)
|
||||
app.router.add_get(r"/install/share/{short_uuid:[A-Za-z0-9_-]{8,128}}", index_route)
|
||||
app.router.add_get(r"/s/{share_token:[a-f0-9]{32}}", index_route)
|
||||
app.router.add_get("/invite", index_route)
|
||||
app.router.add_get("/devices", index_route)
|
||||
app.router.add_get("/settings", index_route)
|
||||
@@ -64,7 +64,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/me", me_route)
|
||||
app.router.add_get("/api/subscription-guides", subscription_guides_route)
|
||||
app.router.add_get(
|
||||
r"/api/subscription-guides/public/{short_uuid:[A-Za-z0-9_-]{8,128}}",
|
||||
r"/api/subscription-guides/public/{share_token:[a-f0-9]{32}}",
|
||||
public_subscription_guides_route,
|
||||
)
|
||||
app.router.add_get("/api/account/avatar", account_avatar_route)
|
||||
|
||||
@@ -53,6 +53,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
if db_user.panel_user_uuid
|
||||
else None
|
||||
)
|
||||
install_share_token = (
|
||||
await subscription_dal.ensure_install_share_token(session, local_sub)
|
||||
if active and local_sub
|
||||
else None
|
||||
)
|
||||
trial_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
@@ -83,7 +88,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
"language_code": lang,
|
||||
"is_admin": is_admin,
|
||||
},
|
||||
"subscription": _serialize_subscription(request, settings, active, local_sub, lang),
|
||||
"subscription": _serialize_subscription(
|
||||
request,
|
||||
settings,
|
||||
active,
|
||||
local_sub,
|
||||
lang,
|
||||
install_share_token=install_share_token,
|
||||
),
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_link,
|
||||
@@ -181,12 +193,26 @@ def _build_webapp_referral_link(
|
||||
|
||||
|
||||
def _serialize_subscription(
|
||||
request: web.Request,
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
lang: str,
|
||||
request_or_settings: Any,
|
||||
settings_or_active: Any,
|
||||
active_or_local_sub: Optional[Any] = None,
|
||||
local_sub_or_lang: Optional[Any] = None,
|
||||
lang: Optional[str] = None,
|
||||
*,
|
||||
install_share_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if lang is None:
|
||||
request = None
|
||||
settings = request_or_settings
|
||||
active = settings_or_active
|
||||
local_sub = active_or_local_sub
|
||||
lang = str(local_sub_or_lang or "ru")
|
||||
else:
|
||||
request = request_or_settings
|
||||
settings = settings_or_active
|
||||
active = active_or_local_sub
|
||||
local_sub = local_sub_or_lang
|
||||
|
||||
if not active:
|
||||
return {
|
||||
"active": False,
|
||||
@@ -196,6 +222,7 @@ def _serialize_subscription(
|
||||
"config_link": None,
|
||||
"connect_url": None,
|
||||
"panel_short_uuid": None,
|
||||
"install_share_token": None,
|
||||
"install_share_url": None,
|
||||
}
|
||||
|
||||
@@ -237,6 +264,9 @@ def _serialize_subscription(
|
||||
can_topup_devices = False
|
||||
|
||||
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip()
|
||||
share_token = str(
|
||||
install_share_token or getattr(local_sub, "install_share_token", "") or ""
|
||||
).strip()
|
||||
return {
|
||||
"active": seconds_left > 0,
|
||||
"status": active.get("status_from_panel") or "UNKNOWN",
|
||||
@@ -247,7 +277,8 @@ def _serialize_subscription(
|
||||
"config_link": active.get("config_link"),
|
||||
"connect_url": active.get("connect_button_url") or active.get("config_link"),
|
||||
"panel_short_uuid": panel_short_uuid or None,
|
||||
"install_share_url": _build_install_share_link(request, settings, panel_short_uuid),
|
||||
"install_share_token": subscription_dal.normalize_install_share_token(share_token) or None,
|
||||
"install_share_url": _build_install_share_link(request, settings, share_token),
|
||||
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
|
||||
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
|
||||
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")),
|
||||
@@ -293,12 +324,12 @@ def _serialize_subscription(
|
||||
|
||||
|
||||
def _build_install_share_link(
|
||||
request: web.Request,
|
||||
request: Optional[web.Request],
|
||||
settings: Settings,
|
||||
short_uuid: str,
|
||||
share_token: str,
|
||||
) -> Optional[str]:
|
||||
short_uuid = str(short_uuid or "").strip()
|
||||
if not short_uuid:
|
||||
share_token = subscription_dal.normalize_install_share_token(share_token)
|
||||
if not share_token or request is None:
|
||||
return None
|
||||
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if configured_base:
|
||||
@@ -315,7 +346,7 @@ def _build_install_share_link(
|
||||
)
|
||||
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
|
||||
base = f"{proto}://{host}"
|
||||
return f"{base.rstrip('/')}/install/share/{quote(short_uuid)}"
|
||||
return f"{base.rstrip('/')}/s/{quote(share_token)}"
|
||||
|
||||
|
||||
def _serialize_plans(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -9,6 +11,8 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Subscription
|
||||
|
||||
INSTALL_SHARE_TOKEN_BYTES = 16
|
||||
|
||||
|
||||
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
model_columns = Subscription.__mapper__.columns.keys()
|
||||
@@ -42,6 +46,77 @@ async def get_subscription_by_panel_subscription_uuid(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def normalize_install_share_token(value: Any) -> str:
|
||||
token = str(value or "").strip().lower()
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", token):
|
||||
return ""
|
||||
return token
|
||||
|
||||
|
||||
async def get_subscription_by_install_share_token(
|
||||
session: AsyncSession,
|
||||
token: str,
|
||||
) -> Optional[Subscription]:
|
||||
normalized = normalize_install_share_token(token)
|
||||
if not normalized:
|
||||
return None
|
||||
stmt = select(Subscription).where(Subscription.install_share_token == normalized)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def ensure_install_share_token(
|
||||
session: AsyncSession,
|
||||
subscription: Subscription,
|
||||
) -> str:
|
||||
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
|
||||
existing = normalize_install_share_token(raw_existing)
|
||||
if existing:
|
||||
if existing != getattr(subscription, "install_share_token", None):
|
||||
subscription.install_share_token = existing
|
||||
await session.flush()
|
||||
return existing
|
||||
|
||||
subscription_id = getattr(subscription, "subscription_id", None)
|
||||
for _attempt in range(10):
|
||||
token = secrets.token_hex(INSTALL_SHARE_TOKEN_BYTES)
|
||||
if await get_subscription_by_install_share_token(session, token):
|
||||
continue
|
||||
if subscription_id:
|
||||
result = await session.execute(
|
||||
update(Subscription)
|
||||
.where(
|
||||
Subscription.subscription_id == subscription_id,
|
||||
or_(
|
||||
Subscription.install_share_token.is_(None),
|
||||
Subscription.install_share_token == "",
|
||||
Subscription.install_share_token == raw_existing,
|
||||
),
|
||||
)
|
||||
.values(install_share_token=token)
|
||||
)
|
||||
await session.flush()
|
||||
if result.rowcount:
|
||||
await session.refresh(subscription)
|
||||
return normalize_install_share_token(
|
||||
getattr(subscription, "install_share_token", None)
|
||||
) or token
|
||||
|
||||
await session.refresh(subscription)
|
||||
raw_existing = str(getattr(subscription, "install_share_token", "") or "").strip()
|
||||
existing = normalize_install_share_token(raw_existing)
|
||||
if existing:
|
||||
return existing
|
||||
continue
|
||||
|
||||
subscription.install_share_token = token
|
||||
await session.flush()
|
||||
await session.refresh(subscription)
|
||||
return token
|
||||
|
||||
raise RuntimeError("Failed to generate a unique install share token")
|
||||
|
||||
|
||||
async def get_active_subscriptions_for_user(
|
||||
session: AsyncSession, user_id: int
|
||||
) -> List[Subscription]:
|
||||
|
||||
@@ -882,6 +882,26 @@ def _migration_0026_add_lifetime_traffic_synced_at(connection: Connection) -> No
|
||||
)
|
||||
|
||||
|
||||
def _migration_0027_add_subscription_install_share_token(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
|
||||
|
||||
if "install_share_token" not in columns:
|
||||
connection.execute(
|
||||
text("ALTER TABLE subscriptions ADD COLUMN install_share_token VARCHAR(32)")
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_subscriptions_install_share_token
|
||||
ON subscriptions (install_share_token)
|
||||
WHERE install_share_token IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1024,6 +1044,11 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track when lifetime traffic usage was last synced from panel",
|
||||
upgrade=_migration_0026_add_lifetime_traffic_synced_at,
|
||||
),
|
||||
Migration(
|
||||
id="0027_add_subscription_install_share_token",
|
||||
description="Add stable public share tokens for install instructions",
|
||||
upgrade=_migration_0027_add_subscription_install_share_token,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ class Subscription(Base):
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
panel_user_uuid = Column(String, nullable=False, index=True)
|
||||
panel_subscription_uuid = Column(String, unique=True, index=True, nullable=True)
|
||||
install_share_token = Column(String(32), unique=True, index=True, nullable=True)
|
||||
start_date = Column(DateTime(timezone=True), nullable=True)
|
||||
end_date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
duration_months = Column(Integer, nullable=True)
|
||||
|
||||
+14
-15
@@ -73,7 +73,7 @@
|
||||
adminSectionFromPath,
|
||||
adminUserIdFromPath,
|
||||
normalizeSection,
|
||||
publicInstallShortUuidFromPath,
|
||||
publicInstallTokenFromPath,
|
||||
sectionFromPath,
|
||||
supportTicketIdFromPath,
|
||||
syncSectionPath,
|
||||
@@ -103,7 +103,7 @@
|
||||
let screen = "home";
|
||||
let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null;
|
||||
let publicInstallSubscription = null;
|
||||
let publicInstallShortUuid = "";
|
||||
let publicInstallToken = "";
|
||||
let trialBusy = false;
|
||||
let promoCode = "";
|
||||
let promoBusy = false;
|
||||
@@ -497,9 +497,9 @@
|
||||
if (mode === "login") loginEmailTooltipOpen = false;
|
||||
};
|
||||
const onPopState = () => {
|
||||
const publicShortUuid = publicInstallShortUuidFromPath(window.location.pathname);
|
||||
if (publicShortUuid) {
|
||||
void loadPublicInstall(publicShortUuid);
|
||||
const shareToken = publicInstallTokenFromPath(window.location.pathname);
|
||||
if (shareToken) {
|
||||
void loadPublicInstall(shareToken);
|
||||
return;
|
||||
}
|
||||
if (mode === "publicInstall") {
|
||||
@@ -802,9 +802,9 @@
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const shareShortUuid = publicInstallShortUuidFromPath(window.location.pathname);
|
||||
if (!MOCK && shareShortUuid) {
|
||||
await loadPublicInstall(shareShortUuid);
|
||||
const shareToken = publicInstallTokenFromPath(window.location.pathname);
|
||||
if (shareToken) {
|
||||
await loadPublicInstall(shareToken);
|
||||
return;
|
||||
}
|
||||
await runWebappBoot({
|
||||
@@ -987,17 +987,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPublicInstall(shortUuid) {
|
||||
async function loadPublicInstall(shareToken) {
|
||||
mode = "publicInstall";
|
||||
screen = "install";
|
||||
activeTab = "home";
|
||||
publicInstallShortUuid = shortUuid;
|
||||
publicInstallToken = shareToken;
|
||||
publicInstallSubscription = {
|
||||
panel_short_uuid: shortUuid,
|
||||
share_url:
|
||||
typeof window !== "undefined" ? `${window.location.origin}/install/share/${shortUuid}` : "",
|
||||
install_share_token: shareToken,
|
||||
share_url: typeof window !== "undefined" ? `${window.location.origin}/s/${shareToken}` : "",
|
||||
};
|
||||
const response = await installGuidesStore.loadPublic(shortUuid, true);
|
||||
const response = await installGuidesStore.loadPublic(shareToken, true);
|
||||
publicInstallSubscription = response?.subscription || publicInstallSubscription;
|
||||
}
|
||||
|
||||
@@ -1362,7 +1361,7 @@
|
||||
{currentLang}
|
||||
telegramPlatform={tg?.platform || ""}
|
||||
user={{}}
|
||||
subscription={publicInstallSubscription || { panel_short_uuid: publicInstallShortUuid }}
|
||||
subscription={publicInstallSubscription || { install_share_token: publicInstallToken }}
|
||||
{goHome}
|
||||
{openConnectLink}
|
||||
{openExternalLink}
|
||||
|
||||
@@ -606,9 +606,13 @@ export async function mockApi(path, options = {}, context = {}) {
|
||||
if (path === "/me") return clone(DEV_MOCK.data);
|
||||
if (path === "/subscription-guides") return clone(DEV_MOCK.data.subscription_guides);
|
||||
if (cleanPath.startsWith("/subscription-guides/public/")) {
|
||||
const shareToken = decodeURIComponent(cleanPath.split("/").pop() || "");
|
||||
const subscription = clone(DEV_MOCK.data.subscription);
|
||||
subscription.install_share_token = shareToken;
|
||||
subscription.share_url = `${window.location.origin}/s/${shareToken}`;
|
||||
return {
|
||||
...clone(DEV_MOCK.data.subscription_guides),
|
||||
subscription: clone(DEV_MOCK.data.subscription),
|
||||
subscription,
|
||||
};
|
||||
}
|
||||
if (path === "/auth/email/request") return { ok: true };
|
||||
|
||||
@@ -276,7 +276,8 @@ export const DEV_MOCK = {
|
||||
config_link: "https://sub.example.com/sub/preview-token",
|
||||
connect_url: "https://sub.example.com/connect/preview-token",
|
||||
panel_short_uuid: "preview-token",
|
||||
install_share_url: "https://app.example.com/install/share/preview-token",
|
||||
install_share_token: "8f559061460e8fede78ef18dce887236",
|
||||
install_share_url: "https://app.example.com/s/8f559061460e8fede78ef18dce887236",
|
||||
traffic_used: "18.4 GB",
|
||||
traffic_limit: "100 GB",
|
||||
traffic_used_bytes: 19756849561,
|
||||
|
||||
@@ -29,10 +29,10 @@ export function sectionFromPath(pathname) {
|
||||
return normalizeSection(section);
|
||||
}
|
||||
|
||||
export function publicInstallShortUuidFromPath(pathname) {
|
||||
export function publicInstallTokenFromPath(pathname) {
|
||||
const normalized = String(pathname || "").trim().replace(/\/+$/, "");
|
||||
const match = normalized.match(/^\/install\/share\/([A-Za-z0-9_-]{8,128})$/);
|
||||
return match ? match[1] : "";
|
||||
const match = normalized.match(/^\/s\/([a-f0-9]{32})$/i);
|
||||
return match ? match[1].toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function adminSectionFromPath(pathname) {
|
||||
|
||||
@@ -62,8 +62,8 @@ export function createInstallGuidesStore({ api, t, showToast }) {
|
||||
return fetchGuides("/subscription-guides", force);
|
||||
}
|
||||
|
||||
async function loadPublic(shortUuid, force = false) {
|
||||
const encoded = encodeURIComponent(String(shortUuid || ""));
|
||||
async function loadPublic(shareToken, force = false) {
|
||||
const encoded = encodeURIComponent(String(shareToken || ""));
|
||||
return fetchGuides(`/subscription-guides/public/${encoded}`, force);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script>
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { fade, fly, scale } from "svelte/transition";
|
||||
import QRCode from "qrcode";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -346,109 +348,146 @@
|
||||
</Card>
|
||||
{:else}
|
||||
|
||||
{#if apps.length > 1}
|
||||
<section class="install-selector-block" aria-label={t("wa_install_app", {}, "App")}>
|
||||
<div class="install-section-title">
|
||||
<span>{t("wa_install_app", {}, "App")}</span>
|
||||
</div>
|
||||
<div
|
||||
class="install-apps"
|
||||
class:apps-mobile-remainder-one={apps.length % 2 === 1}
|
||||
class:apps-remainder-one={apps.length % 3 === 1}
|
||||
class:apps-remainder-two={apps.length % 3 === 2}
|
||||
{#key selectedPlatformKey}
|
||||
{#if apps.length > 1}
|
||||
<section
|
||||
class="install-selector-block"
|
||||
aria-label={t("wa_install_app", {}, "App")}
|
||||
in:fly={{ y: 8, duration: 180, easing: cubicOut }}
|
||||
out:fade={{ duration: 90 }}
|
||||
>
|
||||
{#each apps as app, index}
|
||||
<button
|
||||
class="install-app-button attention-wrap"
|
||||
class:active={selectedAppIndex === index}
|
||||
class:featured={app.featured}
|
||||
type="button"
|
||||
onclick={() => (selectedAppIndex = index)}
|
||||
>
|
||||
{#if app.featured}
|
||||
<AttentionDot class="install-feature-star" />
|
||||
{/if}
|
||||
{#if iconSvg(app.svgIconKey)}
|
||||
<span class="install-svg" aria-hidden="true">{@html iconSvg(app.svgIconKey)}</span>
|
||||
{/if}
|
||||
<span>{app.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="install-section-title">
|
||||
<span>{t("wa_install_app", {}, "App")}</span>
|
||||
</div>
|
||||
<div
|
||||
class="install-apps"
|
||||
class:apps-mobile-remainder-one={apps.length % 2 === 1}
|
||||
class:apps-remainder-one={apps.length % 3 === 1}
|
||||
class:apps-remainder-two={apps.length % 3 === 2}
|
||||
>
|
||||
{#each apps as app, index (`${selectedPlatformKey}:${app.name}:${index}`)}
|
||||
<button
|
||||
class="install-app-button attention-wrap"
|
||||
class:active={selectedAppIndex === index}
|
||||
class:featured={app.featured}
|
||||
type="button"
|
||||
onclick={() => (selectedAppIndex = index)}
|
||||
in:scale={{
|
||||
start: 0.97,
|
||||
duration: 150 + Math.min(index, 5) * 18,
|
||||
easing: cubicOut,
|
||||
}}
|
||||
>
|
||||
{#if app.featured}
|
||||
<AttentionDot class="install-feature-star" />
|
||||
{/if}
|
||||
{#if iconSvg(app.svgIconKey)}
|
||||
<span class="install-svg" aria-hidden="true">{@html iconSvg(app.svgIconKey)}</span>
|
||||
{/if}
|
||||
<span>{app.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
{#if selectedApp}
|
||||
<section class="install-steps" aria-label={selectedApp.name}>
|
||||
{#each selectedApp.blocks as block}
|
||||
<Card class="install-step">
|
||||
<div class="install-step-icon" style={iconColorStyle(block.svgIconColor)} aria-hidden="true">
|
||||
{#if iconSvg(block.svgIconKey)}
|
||||
{@html iconSvg(block.svgIconKey)}
|
||||
{:else}
|
||||
<Check size={19} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="install-step-body">
|
||||
<h2>{localized(block.title)}</h2>
|
||||
<p>{localized(block.description)}</p>
|
||||
{#if block.buttons?.length}
|
||||
<div class="install-actions">
|
||||
{#each block.buttons as button}
|
||||
<Button
|
||||
variant={button.type === "copyButton" ? "secondary" : "default"}
|
||||
onclick={() => handleButton(button)}
|
||||
>
|
||||
{#if button.type === "copyButton"}
|
||||
<Copy size={16} />
|
||||
{:else}
|
||||
<ExternalLink size={16} />
|
||||
{/if}
|
||||
{localized(button.text)}
|
||||
</Button>
|
||||
{/each}
|
||||
{#key `${selectedPlatformKey}:${selectedAppIndex}:${selectedApp.name}`}
|
||||
<section
|
||||
class="install-steps"
|
||||
aria-label={selectedApp.name}
|
||||
in:fly={{ y: 12, duration: 190, easing: cubicOut }}
|
||||
out:fade={{ duration: 90 }}
|
||||
>
|
||||
{#each selectedApp.blocks as block, blockIndex (`${selectedPlatformKey}:${selectedApp.name}:${blockIndex}:${localized(block.title)}`)}
|
||||
<div
|
||||
class="install-step-motion"
|
||||
in:fly={{
|
||||
y: 10,
|
||||
duration: 170 + Math.min(blockIndex, 6) * 18,
|
||||
easing: cubicOut,
|
||||
}}
|
||||
>
|
||||
<Card class="install-step">
|
||||
<div class="install-step-icon" style={iconColorStyle(block.svgIconColor)} aria-hidden="true">
|
||||
{#if iconSvg(block.svgIconKey)}
|
||||
{@html iconSvg(block.svgIconKey)}
|
||||
{:else}
|
||||
<Check size={19} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="install-step-body">
|
||||
<h2>{localized(block.title)}</h2>
|
||||
<p>{localized(block.description)}</p>
|
||||
{#if block.buttons?.length}
|
||||
<div class="install-actions">
|
||||
{#each block.buttons as button}
|
||||
<Button
|
||||
variant={button.type === "copyButton" ? "secondary" : "default"}
|
||||
onclick={() => handleButton(button)}
|
||||
>
|
||||
{#if button.type === "copyButton"}
|
||||
<Copy size={16} />
|
||||
{:else}
|
||||
<ExternalLink size={16} />
|
||||
{/if}
|
||||
{localized(button.text)}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</section>
|
||||
{/key}
|
||||
{#if finalSubscriptionLink && !publicMode}
|
||||
<div class="install-qr-divider" aria-hidden="true">
|
||||
<div
|
||||
class="install-qr-divider"
|
||||
aria-hidden="true"
|
||||
in:fade={{ duration: 180 }}
|
||||
>
|
||||
<svg viewBox="0 0 240 18" preserveAspectRatio="none">
|
||||
<path
|
||||
d="M0 9 Q 4 2 8 9 T 16 9 T 24 9 T 32 9 T 40 9 T 48 9 T 56 9 T 64 9 T 72 9 T 80 9 T 88 9 T 96 9 T 104 9 T 112 9 T 120 9 T 128 9 T 136 9 T 144 9 T 152 9 T 160 9 T 168 9 T 176 9 T 184 9 T 192 9 T 200 9 T 208 9 T 216 9 T 224 9 T 232 9 T 240 9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Card class="install-subscription-card">
|
||||
<div class="install-subscription-header">
|
||||
<div class="install-subscription-header-icon" aria-hidden="true">
|
||||
<QrCode size={20} />
|
||||
</div>
|
||||
<div class="install-subscription-heading">
|
||||
<h2>{t("wa_install_subscription_link", {}, "Subscription link")}</h2>
|
||||
<p>{t("wa_install_subscription_link_hint", {}, "Scan the QR code or copy the link.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-subscription-body">
|
||||
{#if qrDataUrl}
|
||||
<div class="install-qr-wrap">
|
||||
<img src={qrDataUrl} alt={t("wa_install_qr_alt", {}, "Subscription QR code")} />
|
||||
<div
|
||||
class="install-subscription-motion"
|
||||
in:fly={{ y: 10, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
<Card class="install-subscription-card">
|
||||
<div class="install-subscription-header">
|
||||
<div class="install-subscription-header-icon" aria-hidden="true">
|
||||
<QrCode size={20} />
|
||||
</div>
|
||||
<div class="install-subscription-heading">
|
||||
<h2>{t("wa_install_subscription_link", {}, "Subscription link")}</h2>
|
||||
<p>{t("wa_install_subscription_link_hint", {}, "Scan the QR code or copy the link.")}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="install-actions install-subscription-actions">
|
||||
<Button variant="secondary" onclick={copySubscriptionLink}>
|
||||
<Copy size={16} />
|
||||
{t("wa_install_copy_subscription_link", {}, "Copy link")}
|
||||
</Button>
|
||||
<Button onclick={shareInstallGuide}>
|
||||
<Share2 size={16} />
|
||||
{t("wa_install_share", {}, "Share")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="install-subscription-body">
|
||||
{#if qrDataUrl}
|
||||
<div class="install-qr-wrap" in:scale={{ start: 0.96, duration: 180, easing: cubicOut }}>
|
||||
<img src={qrDataUrl} alt={t("wa_install_qr_alt", {}, "Subscription QR code")} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="install-actions install-subscription-actions">
|
||||
<Button variant="secondary" onclick={copySubscriptionLink}>
|
||||
<Copy size={16} />
|
||||
{t("wa_install_copy_subscription_link", {}, "Copy link")}
|
||||
</Button>
|
||||
<Button onclick={shareInstallGuide}>
|
||||
<Share2 size={16} />
|
||||
{t("wa_install_share", {}, "Share")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -551,11 +590,31 @@
|
||||
padding: 10px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.install-apps button.active {
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
||||
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.install-apps button:focus-visible {
|
||||
outline: 0;
|
||||
border-color: color-mix(in srgb, var(--accent) 72%, var(--border));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.install-apps button:active {
|
||||
transform: translateY(0) scale(0.99);
|
||||
}
|
||||
|
||||
:global(.install-platform-trigger) {
|
||||
@@ -703,10 +762,24 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.install-step-motion {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.install-step) {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
:global(.install-step:hover) {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
}
|
||||
|
||||
.install-qr-divider {
|
||||
@@ -736,6 +809,20 @@
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
justify-self: stretch;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.install-subscription-motion {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.install-subscription-card:hover) {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
|
||||
}
|
||||
|
||||
.install-subscription-header {
|
||||
@@ -878,6 +965,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.install-apps button:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 34%, var(--border));
|
||||
background: color-mix(in srgb, var(--text) 4%, var(--panel));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.install-apps button.active:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, var(--panel));
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.install-apps button,
|
||||
:global(.install-step),
|
||||
:global(.install-subscription-card) {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.install-apps button.active,
|
||||
.install-apps button:active,
|
||||
.install-apps button:hover,
|
||||
.install-apps button.active:hover,
|
||||
:global(.install-step:hover),
|
||||
:global(.install-subscription-card:hover) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.install-topbar {
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(260px, 340px);
|
||||
|
||||
@@ -160,6 +160,7 @@ class SubscriptionGuidesRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def test_public_route_returns_shared_config_and_subscription_payload(self):
|
||||
default_uuid = "00000000-0000-0000-0000-000000000000"
|
||||
share_token = "8f559061460e8fede78ef18dce887236"
|
||||
panel_config = json.loads(default_subscription_guides_config_text())
|
||||
panel_service = SimpleNamespace(
|
||||
get_subscription_page_config_list=AsyncMock(
|
||||
@@ -179,17 +180,18 @@ class SubscriptionGuidesRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
request = self._request(
|
||||
self._settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.test/app"),
|
||||
panel_service,
|
||||
match_info={"short_uuid": "share-short"},
|
||||
match_info={"share_token": share_token},
|
||||
)
|
||||
local_sub = SimpleNamespace(
|
||||
panel_user_uuid="panel-user",
|
||||
install_share_token=share_token,
|
||||
is_active=True,
|
||||
end_date=datetime.now(timezone.utc) + timedelta(days=3),
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
guides.subscription_dal,
|
||||
"get_subscription_by_panel_subscription_uuid",
|
||||
"get_subscription_by_install_share_token",
|
||||
AsyncMock(return_value=local_sub),
|
||||
):
|
||||
response = await guides.public_subscription_guides_route(request)
|
||||
@@ -199,8 +201,9 @@ class SubscriptionGuidesRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(body["subscription"]["config_link"], "https://sb.example.test/share-short")
|
||||
self.assertEqual(
|
||||
body["subscription"]["share_url"],
|
||||
"https://app.example.test/install/share/share-short",
|
||||
f"https://app.example.test/s/{share_token}",
|
||||
)
|
||||
self.assertEqual(body["subscription"]["install_share_token"], share_token)
|
||||
panel_service.get_user_by_uuid.assert_awaited_once_with("panel-user")
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("GET", "/login/password"): "index_route",
|
||||
("GET", "/home"): "index_route",
|
||||
("GET", "/install"): "index_route",
|
||||
("GET", "/install/share/{short_uuid}"): "index_route",
|
||||
("GET", "/s/{share_token}"): "index_route",
|
||||
("GET", "/invite"): "index_route",
|
||||
("GET", "/devices"): "index_route",
|
||||
("GET", "/settings"): "index_route",
|
||||
@@ -82,7 +82,7 @@ class WebAppRouteContractTests(unittest.TestCase):
|
||||
("GET", "/api/subscription-guides"): "subscription_guides_route",
|
||||
(
|
||||
"GET",
|
||||
"/api/subscription-guides/public/{short_uuid}",
|
||||
"/api/subscription-guides/public/{share_token}",
|
||||
): "public_subscription_guides_route",
|
||||
("GET", "/api/account/avatar"): "account_avatar_route",
|
||||
("POST", "/api/account/language"): "account_language_route",
|
||||
|
||||
Reference in New Issue
Block a user