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
+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);