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: async def public_subscription_guides_route(request: web.Request) -> web.Response:
short_uuid = _normalize_short_uuid(request.match_info.get("short_uuid")) share_token = subscription_dal.normalize_install_share_token(
if not short_uuid: request.match_info.get("share_token")
return web.json_response({"ok": False, "error": "invalid_short_uuid"}, status=404) )
if not share_token:
return web.json_response({"ok": False, "error": "invalid_share_token"}, status=404)
status = await _subscription_guides_status_shared(request.app) 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 = { payload = {
"enabled": bool(status.get("enabled")), "enabled": bool(status.get("enabled")),
"config": status.get("config") if status.get("enabled") else None, "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( async def _public_subscription_payload(
request: web.Request, request: web.Request,
short_uuid: str, share_token: str,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
panel_service = _panel_service_from_app(request.app) panel_service = _panel_service_from_app(request.app)
raw_link = "" raw_link = ""
username = "" username = ""
resolved_short_uuid = short_uuid resolved_short_uuid = ""
async_session_factory: sessionmaker = request.app["async_session_factory"] async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session: 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, session,
short_uuid, share_token,
) )
if ( if (
@@ -185,16 +187,17 @@ async def _public_subscription_payload(
if panel_user: if panel_user:
raw_link = str(panel_user.get("subscriptionUrl") or "").strip() raw_link = str(panel_user.get("subscriptionUrl") or "").strip()
username = str(panel_user.get("username") 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) display_link, connect_url = await prepare_config_links(settings, raw_link)
return { return {
"active": bool(display_link), "active": bool(display_link),
"config_link": display_link, "config_link": display_link,
"connect_url": connect_url or 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, "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: def _public_install_url(request: web.Request, share_token: str) -> 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:
settings: Settings = request.app["settings"] settings: Settings = request.app["settings"]
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip() configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base: 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" proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}" 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]: 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("/login/password", index_route)
app.router.add_get("/home", index_route) app.router.add_get("/home", index_route)
app.router.add_get("/install", 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("/invite", index_route)
app.router.add_get("/devices", index_route) app.router.add_get("/devices", index_route)
app.router.add_get("/settings", 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/me", me_route)
app.router.add_get("/api/subscription-guides", subscription_guides_route) app.router.add_get("/api/subscription-guides", subscription_guides_route)
app.router.add_get( 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, public_subscription_guides_route,
) )
app.router.add_get("/api/account/avatar", account_avatar_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 if db_user.panel_user_uuid
else None 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( trial_available = bool(
settings.TRIAL_ENABLED settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0 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, "language_code": lang,
"is_admin": is_admin, "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": { "referral": {
"code": referral_code, "code": referral_code,
"bot_link": referral_link, "bot_link": referral_link,
@@ -181,12 +193,26 @@ def _build_webapp_referral_link(
def _serialize_subscription( def _serialize_subscription(
request: web.Request, request_or_settings: Any,
settings: Settings, settings_or_active: Any,
active: Optional[Dict[str, Any]], active_or_local_sub: Optional[Any] = None,
local_sub: Optional[Any], local_sub_or_lang: Optional[Any] = None,
lang: str, lang: Optional[str] = None,
*,
install_share_token: Optional[str] = None,
) -> Dict[str, Any]: ) -> 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: if not active:
return { return {
"active": False, "active": False,
@@ -196,6 +222,7 @@ def _serialize_subscription(
"config_link": None, "config_link": None,
"connect_url": None, "connect_url": None,
"panel_short_uuid": None, "panel_short_uuid": None,
"install_share_token": None,
"install_share_url": None, "install_share_url": None,
} }
@@ -237,6 +264,9 @@ def _serialize_subscription(
can_topup_devices = False can_topup_devices = False
panel_short_uuid = str(active.get("panel_short_uuid") or "").strip() 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 { return {
"active": seconds_left > 0, "active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN", "status": active.get("status_from_panel") or "UNKNOWN",
@@ -247,7 +277,8 @@ def _serialize_subscription(
"config_link": active.get("config_link"), "config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"), "connect_url": active.get("connect_button_url") or active.get("config_link"),
"panel_short_uuid": panel_short_uuid or None, "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_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")), "traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_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( def _build_install_share_link(
request: web.Request, request: Optional[web.Request],
settings: Settings, settings: Settings,
short_uuid: str, share_token: str,
) -> Optional[str]: ) -> Optional[str]:
short_uuid = str(short_uuid or "").strip() share_token = subscription_dal.normalize_install_share_token(share_token)
if not short_uuid: if not share_token or request is None:
return None return None
configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip() configured_base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if configured_base: if configured_base:
@@ -315,7 +346,7 @@ def _build_install_share_link(
) )
proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https" proto = request.headers.get("X-Forwarded-Proto") or request.scheme or "https"
base = f"{proto}://{host}" base = f"{proto}://{host}"
return f"{base.rstrip('/')}/install/share/{quote(short_uuid)}" return f"{base.rstrip('/')}/s/{quote(share_token)}"
def _serialize_plans( def _serialize_plans(
+75
View File
@@ -1,4 +1,6 @@
import logging import logging
import re
import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -9,6 +11,8 @@ from sqlalchemy.orm import selectinload
from db.models import Subscription from db.models import Subscription
INSTALL_SHARE_TOKEN_BYTES = 16
def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]: def _subscription_model_payload(sub_payload: Dict[str, Any]) -> Dict[str, Any]:
model_columns = Subscription.__mapper__.columns.keys() model_columns = Subscription.__mapper__.columns.keys()
@@ -42,6 +46,77 @@ async def get_subscription_by_panel_subscription_uuid(
return result.scalar_one_or_none() 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( async def get_active_subscriptions_for_user(
session: AsyncSession, user_id: int session: AsyncSession, user_id: int
) -> List[Subscription]: ) -> 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] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -1024,6 +1044,11 @@ MIGRATIONS: List[Migration] = [
description="Track when lifetime traffic usage was last synced from panel", description="Track when lifetime traffic usage was last synced from panel",
upgrade=_migration_0026_add_lifetime_traffic_synced_at, 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) user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
panel_user_uuid = Column(String, nullable=False, index=True) panel_user_uuid = Column(String, nullable=False, index=True)
panel_subscription_uuid = Column(String, unique=True, index=True, nullable=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) start_date = Column(DateTime(timezone=True), nullable=True)
end_date = Column(DateTime(timezone=True), nullable=False, index=True) end_date = Column(DateTime(timezone=True), nullable=False, index=True)
duration_months = Column(Integer, nullable=True) duration_months = Column(Integer, nullable=True)
+14 -15
View File
@@ -73,7 +73,7 @@
adminSectionFromPath, adminSectionFromPath,
adminUserIdFromPath, adminUserIdFromPath,
normalizeSection, normalizeSection,
publicInstallShortUuidFromPath, publicInstallTokenFromPath,
sectionFromPath, sectionFromPath,
supportTicketIdFromPath, supportTicketIdFromPath,
syncSectionPath, syncSectionPath,
@@ -103,7 +103,7 @@
let screen = "home"; let screen = "home";
let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null; let data = isPreviewBoard ? structuredCloneSafe(DEV_MOCK.data) : null;
let publicInstallSubscription = null; let publicInstallSubscription = null;
let publicInstallShortUuid = ""; let publicInstallToken = "";
let trialBusy = false; let trialBusy = false;
let promoCode = ""; let promoCode = "";
let promoBusy = false; let promoBusy = false;
@@ -497,9 +497,9 @@
if (mode === "login") loginEmailTooltipOpen = false; if (mode === "login") loginEmailTooltipOpen = false;
}; };
const onPopState = () => { const onPopState = () => {
const publicShortUuid = publicInstallShortUuidFromPath(window.location.pathname); const shareToken = publicInstallTokenFromPath(window.location.pathname);
if (publicShortUuid) { if (shareToken) {
void loadPublicInstall(publicShortUuid); void loadPublicInstall(shareToken);
return; return;
} }
if (mode === "publicInstall") { if (mode === "publicInstall") {
@@ -802,9 +802,9 @@
} }
async function boot() { async function boot() {
const shareShortUuid = publicInstallShortUuidFromPath(window.location.pathname); const shareToken = publicInstallTokenFromPath(window.location.pathname);
if (!MOCK && shareShortUuid) { if (shareToken) {
await loadPublicInstall(shareShortUuid); await loadPublicInstall(shareToken);
return; return;
} }
await runWebappBoot({ await runWebappBoot({
@@ -987,17 +987,16 @@
} }
} }
async function loadPublicInstall(shortUuid) { async function loadPublicInstall(shareToken) {
mode = "publicInstall"; mode = "publicInstall";
screen = "install"; screen = "install";
activeTab = "home"; activeTab = "home";
publicInstallShortUuid = shortUuid; publicInstallToken = shareToken;
publicInstallSubscription = { publicInstallSubscription = {
panel_short_uuid: shortUuid, install_share_token: shareToken,
share_url: share_url: typeof window !== "undefined" ? `${window.location.origin}/s/${shareToken}` : "",
typeof window !== "undefined" ? `${window.location.origin}/install/share/${shortUuid}` : "",
}; };
const response = await installGuidesStore.loadPublic(shortUuid, true); const response = await installGuidesStore.loadPublic(shareToken, true);
publicInstallSubscription = response?.subscription || publicInstallSubscription; publicInstallSubscription = response?.subscription || publicInstallSubscription;
} }
@@ -1362,7 +1361,7 @@
{currentLang} {currentLang}
telegramPlatform={tg?.platform || ""} telegramPlatform={tg?.platform || ""}
user={{}} user={{}}
subscription={publicInstallSubscription || { panel_short_uuid: publicInstallShortUuid }} subscription={publicInstallSubscription || { install_share_token: publicInstallToken }}
{goHome} {goHome}
{openConnectLink} {openConnectLink}
{openExternalLink} {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 === "/me") return clone(DEV_MOCK.data);
if (path === "/subscription-guides") return clone(DEV_MOCK.data.subscription_guides); if (path === "/subscription-guides") return clone(DEV_MOCK.data.subscription_guides);
if (cleanPath.startsWith("/subscription-guides/public/")) { 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 { return {
...clone(DEV_MOCK.data.subscription_guides), ...clone(DEV_MOCK.data.subscription_guides),
subscription: clone(DEV_MOCK.data.subscription), subscription,
}; };
} }
if (path === "/auth/email/request") return { ok: true }; 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", config_link: "https://sub.example.com/sub/preview-token",
connect_url: "https://sub.example.com/connect/preview-token", connect_url: "https://sub.example.com/connect/preview-token",
panel_short_uuid: "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_used: "18.4 GB",
traffic_limit: "100 GB", traffic_limit: "100 GB",
traffic_used_bytes: 19756849561, traffic_used_bytes: 19756849561,
+3 -3
View File
@@ -29,10 +29,10 @@ export function sectionFromPath(pathname) {
return normalizeSection(section); return normalizeSection(section);
} }
export function publicInstallShortUuidFromPath(pathname) { export function publicInstallTokenFromPath(pathname) {
const normalized = String(pathname || "").trim().replace(/\/+$/, ""); const normalized = String(pathname || "").trim().replace(/\/+$/, "");
const match = normalized.match(/^\/install\/share\/([A-Za-z0-9_-]{8,128})$/); const match = normalized.match(/^\/s\/([a-f0-9]{32})$/i);
return match ? match[1] : ""; return match ? match[1].toLowerCase() : "";
} }
export function adminSectionFromPath(pathname) { export function adminSectionFromPath(pathname) {
@@ -62,8 +62,8 @@ export function createInstallGuidesStore({ api, t, showToast }) {
return fetchGuides("/subscription-guides", force); return fetchGuides("/subscription-guides", force);
} }
async function loadPublic(shortUuid, force = false) { async function loadPublic(shareToken, force = false) {
const encoded = encodeURIComponent(String(shortUuid || "")); const encoded = encodeURIComponent(String(shareToken || ""));
return fetchGuides(`/subscription-guides/public/${encoded}`, force); return fetchGuides(`/subscription-guides/public/${encoded}`, force);
} }
@@ -1,5 +1,7 @@
<script> <script>
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { fade, fly, scale } from "svelte/transition";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { import {
ArrowLeft, ArrowLeft,
@@ -346,8 +348,14 @@
</Card> </Card>
{:else} {:else}
{#key selectedPlatformKey}
{#if apps.length > 1} {#if apps.length > 1}
<section class="install-selector-block" aria-label={t("wa_install_app", {}, "App")}> <section
class="install-selector-block"
aria-label={t("wa_install_app", {}, "App")}
in:fly={{ y: 8, duration: 180, easing: cubicOut }}
out:fade={{ duration: 90 }}
>
<div class="install-section-title"> <div class="install-section-title">
<span>{t("wa_install_app", {}, "App")}</span> <span>{t("wa_install_app", {}, "App")}</span>
</div> </div>
@@ -357,13 +365,18 @@
class:apps-remainder-one={apps.length % 3 === 1} class:apps-remainder-one={apps.length % 3 === 1}
class:apps-remainder-two={apps.length % 3 === 2} class:apps-remainder-two={apps.length % 3 === 2}
> >
{#each apps as app, index} {#each apps as app, index (`${selectedPlatformKey}:${app.name}:${index}`)}
<button <button
class="install-app-button attention-wrap" class="install-app-button attention-wrap"
class:active={selectedAppIndex === index} class:active={selectedAppIndex === index}
class:featured={app.featured} class:featured={app.featured}
type="button" type="button"
onclick={() => (selectedAppIndex = index)} onclick={() => (selectedAppIndex = index)}
in:scale={{
start: 0.97,
duration: 150 + Math.min(index, 5) * 18,
easing: cubicOut,
}}
> >
{#if app.featured} {#if app.featured}
<AttentionDot class="install-feature-star" /> <AttentionDot class="install-feature-star" />
@@ -377,10 +390,25 @@
</div> </div>
</section> </section>
{/if} {/if}
{/key}
{#if selectedApp} {#if selectedApp}
<section class="install-steps" aria-label={selectedApp.name}> {#key `${selectedPlatformKey}:${selectedAppIndex}:${selectedApp.name}`}
{#each selectedApp.blocks as block} <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"> <Card class="install-step">
<div class="install-step-icon" style={iconColorStyle(block.svgIconColor)} aria-hidden="true"> <div class="install-step-icon" style={iconColorStyle(block.svgIconColor)} aria-hidden="true">
{#if iconSvg(block.svgIconKey)} {#if iconSvg(block.svgIconKey)}
@@ -411,16 +439,26 @@
{/if} {/if}
</div> </div>
</Card> </Card>
</div>
{/each} {/each}
</section> </section>
{/key}
{#if finalSubscriptionLink && !publicMode} {#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"> <svg viewBox="0 0 240 18" preserveAspectRatio="none">
<path <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" 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> </svg>
</div> </div>
<div
class="install-subscription-motion"
in:fly={{ y: 10, duration: 200, easing: cubicOut }}
>
<Card class="install-subscription-card"> <Card class="install-subscription-card">
<div class="install-subscription-header"> <div class="install-subscription-header">
<div class="install-subscription-header-icon" aria-hidden="true"> <div class="install-subscription-header-icon" aria-hidden="true">
@@ -433,7 +471,7 @@
</div> </div>
<div class="install-subscription-body"> <div class="install-subscription-body">
{#if qrDataUrl} {#if qrDataUrl}
<div class="install-qr-wrap"> <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")} /> <img src={qrDataUrl} alt={t("wa_install_qr_alt", {}, "Subscription QR code")} />
</div> </div>
{/if} {/if}
@@ -449,6 +487,7 @@
</div> </div>
</div> </div>
</Card> </Card>
</div>
{/if} {/if}
{/if} {/if}
{/if} {/if}
@@ -551,11 +590,31 @@
padding: 10px; padding: 10px;
font: inherit; font: inherit;
text-align: left; 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 { .install-apps button.active {
border-color: color-mix(in srgb, var(--accent) 70%, var(--border)); border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
background: color-mix(in srgb, var(--accent) 12%, var(--panel)); 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) { :global(.install-platform-trigger) {
@@ -703,10 +762,24 @@
gap: 10px; gap: 10px;
} }
.install-step-motion {
min-width: 0;
}
:global(.install-step) { :global(.install-step) {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
gap: 12px; 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 { .install-qr-divider {
@@ -736,6 +809,20 @@
display: grid; display: grid;
gap: 12px; gap: 12px;
justify-self: stretch; 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 { .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) { @media (min-width: 1024px) {
.install-topbar { .install-topbar {
grid-template-columns: auto minmax(0, 1fr) minmax(260px, 340px); 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): async def test_public_route_returns_shared_config_and_subscription_payload(self):
default_uuid = "00000000-0000-0000-0000-000000000000" default_uuid = "00000000-0000-0000-0000-000000000000"
share_token = "8f559061460e8fede78ef18dce887236"
panel_config = json.loads(default_subscription_guides_config_text()) panel_config = json.loads(default_subscription_guides_config_text())
panel_service = SimpleNamespace( panel_service = SimpleNamespace(
get_subscription_page_config_list=AsyncMock( get_subscription_page_config_list=AsyncMock(
@@ -179,17 +180,18 @@ class SubscriptionGuidesRouteTests(unittest.IsolatedAsyncioTestCase):
request = self._request( request = self._request(
self._settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.test/app"), self._settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.test/app"),
panel_service, panel_service,
match_info={"short_uuid": "share-short"}, match_info={"share_token": share_token},
) )
local_sub = SimpleNamespace( local_sub = SimpleNamespace(
panel_user_uuid="panel-user", panel_user_uuid="panel-user",
install_share_token=share_token,
is_active=True, is_active=True,
end_date=datetime.now(timezone.utc) + timedelta(days=3), end_date=datetime.now(timezone.utc) + timedelta(days=3),
) )
with patch.object( with patch.object(
guides.subscription_dal, guides.subscription_dal,
"get_subscription_by_panel_subscription_uuid", "get_subscription_by_install_share_token",
AsyncMock(return_value=local_sub), AsyncMock(return_value=local_sub),
): ):
response = await guides.public_subscription_guides_route(request) 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"]["config_link"], "https://sb.example.test/share-short")
self.assertEqual( self.assertEqual(
body["subscription"]["share_url"], 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") 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", "/login/password"): "index_route",
("GET", "/home"): "index_route", ("GET", "/home"): "index_route",
("GET", "/install"): "index_route", ("GET", "/install"): "index_route",
("GET", "/install/share/{short_uuid}"): "index_route", ("GET", "/s/{share_token}"): "index_route",
("GET", "/invite"): "index_route", ("GET", "/invite"): "index_route",
("GET", "/devices"): "index_route", ("GET", "/devices"): "index_route",
("GET", "/settings"): "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"): "subscription_guides_route",
( (
"GET", "GET",
"/api/subscription-guides/public/{short_uuid}", "/api/subscription-guides/public/{share_token}",
): "public_subscription_guides_route", ): "public_subscription_guides_route",
("GET", "/api/account/avatar"): "account_avatar_route", ("GET", "/api/account/avatar"): "account_avatar_route",
("POST", "/api/account/language"): "account_language_route", ("POST", "/api/account/language"): "account_language_route",