feat: add install guide share tokens and animations
This commit is contained in:
+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);
|
||||
|
||||
Reference in New Issue
Block a user