feat: add install guide share tokens and animations

This commit is contained in:
3252a8
2026-05-22 23:20:25 +03:00
parent d3925cad22
commit d9a4a007e2
14 changed files with 403 additions and 150 deletions
+16 -20
View File
@@ -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]:
+2 -2
View File
@@ -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)
+43 -12
View File
@@ -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(
+75
View File
@@ -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]:
+25
View File
@@ -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,
),
]
+1
View File
@@ -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
View File
@@ -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}
+5 -1
View File
@@ -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 };
+2 -1
View File
@@ -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,
+3 -3
View File
@@ -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);
+6 -3
View File
@@ -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")
+2 -2
View File
@@ -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",