<template>
    <div class="full">
        <div class="header">
            <div class="row">
                <span class="dot" :class="{ idle: !connected }" />
                <span class="title">PROOF OF PERSONHOOD &middot; LIVE</span>
            </div>
        </div>

        <div class="grid">
            <div class="col col-transcript">
                <div class="muted small">TRANSCRIPT</div>
                <div class="transcript">
                    <p
                        v-for="(l, i) in completed"
                        :key="`c-${i}`"
                        class="completed"
                    >
                        {{ l }}
                    </p>
                    <p class="live">{{ current }}<span class="caret"></span></p>
                </div>
            </div>

            <div class="col col-right">
                <template v-if="sealStage !== 'sealed'">
                    <div class="muted small">BLOCK BEACONS</div>
                    <div class="beacons mono">
                        <div v-for="b in beacons" :key="b.n" class="beacon">
                            <span class="n">#{{ b.n.toLocaleString() }}</span>
                            <span class="h">0x{{ b.hash }}</span>
                            <span class="t">{{ b.t }}</span>
                        </div>
                    </div>
                </template>

                <div v-else class="receipt-big">
                    <div class="qr-card-big">
                        <img
                            v-if="qrDataUrl"
                            :src="qrDataUrl"
                            class="qr-img"
                            alt="proof receipt"
                        />
                    </div>
                    <div class="receipt-meta">
                        <div class="receipt-label">Receipt</div>
                        <div class="receipt-line">
                            captured at block
                            <span class="mono"
                                >#{{ anchorBlock.toLocaleString() }}</span
                            >
                        </div>
                        <div class="receipt-line dim">scan to verify</div>
                    </div>
                </div>
            </div>
        </div>

        <div class="footer" v-if="sealStage !== 'sealed'">
            <button
                v-if="sealStage === 'idle'"
                class="qr-trigger"
                type="button"
                @click="onTrigger"
            >
                <span class="trigger-icon">&#x29C9;</span>
                <span class="trigger-label">Seal presentation</span>
            </button>

            <div v-else class="signing" @click="onStatusClick">
                <span class="spinner"></span>
                <span class="status">{{ statusMessage }}</span>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";

// ---- STT WebSocket (from companion/main.py) -------------------------------

const WS_URL = "ws://localhost:8787";
const MAX_COMPLETED = 4;

const completed = ref<string[]>([]);
const current = ref("");
const connected = ref(false);

// Rolling log of everything that happened during the talk — sentences and
// real blocks interleaved in order. Becomes the artifact's `chain[]` at
// seal time. Shape matches what proofoftalk's buildChain() expects.
type ChainEntry =
    | { s: string }
    | { h: string; blk: string; time: string; full: string };
const chainLog: ChainEntry[] = [];
const startedAt = ref<string | null>(null);
// True between "sentence pushed" and "next block landed" — keeps chainLog as
// sentence/block alternation so quiet stretches don't bloat the artifact.
let awaitingBlock = false;

// Queue of finalized utterances waiting to be typed out. We keep the
// typewriter aesthetic on top of real events for visual continuity, but
// skip ahead when the queue grows so the panel doesn't fall behind the
// speaker.
const pending: string[] = [];
let typeTimer: ReturnType<typeof setTimeout> | undefined;

function commitCurrentToHistory(text: string) {
    completed.value.push(text);
    if (completed.value.length > MAX_COMPLETED) completed.value.shift();
    // First sentence: anchor with the latest known block so the chain has a
    // "couldn't exist before this block" rivet at the head, not just at the
    // tail. Subsequent sentences rely on the next block landing.
    if (chainLog.length === 0) {
        const seed = rawBeacons.value[0];
        if (seed) chainLog.push(makeBlockEntry(seed));
    }
    chainLog.push({ s: text });
    awaitingBlock = true;
    if (!startedAt.value) startedAt.value = new Date().toISOString();
}

function startTypingNext() {
    if (current.value !== "" || pending.length === 0) return;
    const next = pending.shift() as string;
    let i = 0;
    const step = () => {
        i++;
        current.value = next.slice(0, i);

        if (i >= next.length) {
            typeTimer = setTimeout(() => {
                commitCurrentToHistory(next);
                current.value = "";
                startTypingNext();
            }, 350);
            return;
        }

        const ch = next[i - 1];
        let delay = 22 + Math.random() * 16;
        if (ch === ".") delay = 320;
        else if (ch === "," || ch === "?" || ch === "—") delay = 160;
        else if (ch === " ") delay = 28;
        typeTimer = setTimeout(step, delay);
    };
    step();
}

function enqueueText(text: string) {
    const cleaned = text.trim();
    if (!cleaned) return;
    pending.push(cleaned);

    // If the speaker is faster than the typewriter, skip ahead so we don't
    // fall further behind. Finish the current line immediately and let the
    // queue drain.
    if (pending.length > 1 && current.value !== "") {
        if (typeTimer) clearTimeout(typeTimer);
        commitCurrentToHistory(current.value);
        current.value = "";
    }
    startTypingNext();
}

// ---- WebSocket with auto-reconnect ----------------------------------------

let ws: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let teardown = false;

function connect() {
    if (teardown) return;
    try {
        ws = new WebSocket(WS_URL);
    } catch {
        reconnectTimer = setTimeout(connect, 1500);
        return;
    }

    ws.onopen = () => {
        connected.value = true;
    };
    ws.onmessage = (e) => {
        try {
            const event = JSON.parse(e.data);
            if (
                event &&
                event.type === "final" &&
                typeof event.text === "string"
            ) {
                enqueueText(event.text);
            }
        } catch {
            /* ignore malformed */
        }
    };
    ws.onclose = () => {
        connected.value = false;
        ws = undefined;
        if (!teardown) reconnectTimer = setTimeout(connect, 1500);
    };
    ws.onerror = () => {
        try {
            ws?.close();
        } catch {}
    };
}

// Hard stop: prevents new sentences from landing after Seal. Closes the WS,
// kills the reconnect loop, drains any pending typewriter work. The partial
// current line is left on screen as the last thing the speaker said.
function stopStt() {
    teardown = true;
    if (reconnectTimer) {
        clearTimeout(reconnectTimer);
        reconnectTimer = undefined;
    }
    if (typeTimer) {
        clearTimeout(typeTimer);
        typeTimer = undefined;
    }
    pending.length = 0;
    if (ws) {
        ws.onclose = null;
        try {
            ws.close();
        } catch {}
        ws = undefined;
    }
    connected.value = false;
}

// ---- Block beacons (live from Paseo Next v2 Asset Hub) --------------------

import { computed, watch } from "vue";
import { useBlockBeacons } from "../composables/useBlockBeacons";

const { beacons: rawBeacons } = useBlockBeacons();

function makeBlockEntry(b: {
    number: number;
    hash: string;
    receivedAt: number;
}): ChainEntry {
    const d = new Date(b.receivedAt);
    const pad = (n: number) => String(n).padStart(2, "0");
    const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    const raw = b.hash.startsWith("0x") ? b.hash.slice(2) : b.hash;
    return {
        h: `0x${raw.slice(0, 4)}…${raw.slice(-4)}`,
        blk: b.number.toLocaleString(),
        time,
        full: b.hash,
    };
}

// Append a new top block to chainLog only when a sentence is waiting to be
// anchored — keeps quiet stretches from cluttering the artifact and the
// downstream receipt view.
watch(rawBeacons, (next, prev) => {
    const top = next[0];
    if (!top) return;
    if (prev?.[0]?.hash === top.hash) return;
    if (!awaitingBlock) return;
    chainLog.push(makeBlockEntry(top));
    awaitingBlock = false;
});
const nowTick = ref(Date.now());
let beaconTickTimer: ReturnType<typeof setInterval> | undefined;

function shortBeaconHash(full: string): string {
    const raw = full.startsWith("0x") ? full.slice(2) : full;
    return `${raw.slice(0, 6)}…${raw.slice(-3)}`;
}

function ago(receivedAt: number, now: number): string {
    const secs = Math.max(0, Math.floor((now - receivedAt) / 1000));
    if (secs < 2) return "just now";
    return `${secs}s ago`;
}

// Adapt the composable's {number, hash, receivedAt} into the {n, hash, t}
// shape the existing template renders, capping at 5 rows.
const beacons = computed(() =>
    rawBeacons.value.slice(0, 5).map((b) => ({
        n: b.number,
        hash: shortBeaconHash(b.hash),
        t: ago(b.receivedAt, nowTick.value),
    })),
);

// ---- Seal sequence: real sign + Bulletin upload --------------------------

import QRCode from "qrcode";
import {
    isInsideContainer,
    getAccountsProvider,
    getHostProvider,
    getPreimageManager,
    getTruApi,
} from "@parity/product-sdk-host";
import { u8aToHex } from "@polkadot/util";
import { CID } from "multiformats/cid";
import { create as createMultihashDigest } from "multiformats/hashes/digest";
import { blake2b } from "@noble/hashes/blake2.js";
import {
    AccountId,
    Binary,
    createClient,
    type PolkadotClient,
} from "polkadot-api";

// Bulletin's CID convention: CIDv1 + raw codec + blake2b-256 multihash.
const IPFS_CODEC_RAW = 0x55;
const IPFS_HASH_BLAKE2B_256 = 0xb220;

// Paseo People-chain — where Resources.UsernameOwnerOf lives. Same value as
// host-playground's PASEO_PEOPLE_GENESIS; swap for previewnet if the deck
// ever runs against a different hub.
const PEOPLE_GENESIS =
    "0xbe5238f82c3553bc57ac3be43bef110bd58c49ad0744110814985195ca7d8c4e" as const;

let _hostInitPromise: Promise<boolean> | null = null;
let accountsProvider: Awaited<ReturnType<typeof getAccountsProvider>> = null;
let preimageManager: Awaited<ReturnType<typeof getPreimageManager>> = null;
let truApi: Awaited<ReturnType<typeof getTruApi>> = null;
let bulletinAllowanceGranted = false;

function ensureHost(): Promise<boolean> {
    if (!_hostInitPromise) {
        _hostInitPromise = (async () => {
            const inside = await isInsideContainer();
            if (!inside) return false;
            accountsProvider = await getAccountsProvider();
            preimageManager = await getPreimageManager();
            truApi = await getTruApi();
            return !!(accountsProvider && preimageManager && truApi);
        })().catch(() => false);
    }
    return _hostInitPromise;
}

function cidForBytes(bytes: Uint8Array): string {
    const digest = blake2b(bytes, { dkLen: 32 });
    const mh = createMultihashDigest(IPFS_HASH_BLAKE2B_256, digest);
    return CID.createV1(IPFS_CODEC_RAW, mh).toString();
}

function formatWindow(startIso: string, endIso: string): string {
    const pad = (n: number) => String(n).padStart(2, "0");
    const fmt = (iso: string) => {
        const d = new Date(iso);
        return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
    };
    return `${fmt(startIso)}–${fmt(endIso)}`;
}

// Canonical signing convention: strip sig/pubkey/sig_alg, sort top-level keys,
// JSON.stringify, UTF-8 encode. MUST stay byte-identical to the verifier's
// implementation in proofoftalk/src/main.js — drift here breaks verification.
function canonicalSigningBytes(artifact: Record<string, unknown>): Uint8Array {
    const {
        sig: _sig,
        pubkey: _pubkey,
        sig_alg: _sigAlg,
        ...rest
    } = artifact as any;
    const sorted: Record<string, unknown> = {};
    for (const k of Object.keys(rest).sort()) sorted[k] = rest[k];
    return new TextEncoder().encode(JSON.stringify(sorted));
}

let peopleClient: PolkadotClient | null = null;
async function getPeopleClient(): Promise<PolkadotClient> {
    if (peopleClient) return peopleClient;
    const provider = await getHostProvider(PEOPLE_GENESIS);
    if (!provider) throw new Error("getHostProvider returned null");
    peopleClient = createClient(provider);
    return peopleClient;
}

async function resolveLegacyAccount(): Promise<{
    publicKey: Uint8Array;
    name: string;
}> {
    if (!accountsProvider) throw new Error("accountsProvider not ready");
    const userIdRes = await accountsProvider.getUserId();
    let username: string | null = null;
    userIdRes.match(
        (id: any) => {
            username = id.primaryUsername;
        },
        (err: any) => {
            throw new Error(`getUserId: ${err?.value?.name ?? err}`);
        },
    );
    if (!username) throw new Error("getUserId returned no primary username");

    const peopleApi = (await getPeopleClient()).getUnsafeApi();
    const ownerAddress =
        (await peopleApi.query.Resources.UsernameOwnerOf.getValue(
            Binary.fromText(username),
        )) as string | undefined;
    if (!ownerAddress)
        throw new Error(`No People-chain account owns username "${username}"`);

    return { publicKey: AccountId().enc(ownerAddress), name: username };
}

async function ensureBulletinAllowance() {
    if (bulletinAllowanceGranted) return;
    if (!truApi) throw new Error("truApi unavailable — host bridge not ready");
    const res = await truApi.requestResourceAllocation({
        tag: "v1",
        value: [{ tag: "BulletinAllowance", value: undefined }],
    });
    if (res?.match) {
        let thrown: Error | null = null;
        res.match(
            () => {},
            (err: any) => {
                thrown = new Error(
                    `BulletinAllowance: ${err?.value?.name ?? err}`,
                );
            },
        );
        if (thrown) throw thrown;
    }
    bulletinAllowanceGranted = true;
}

type SealStage = "idle" | "signing" | "sealed";
const sealStage = ref<SealStage>("idle");
const statusMessage = ref("");
const qrDataUrl = ref("");
const anchorBlock = ref(0);

function onStatusClick() {
    // Tap the status line to dismiss an error and return to the button.
    if (statusMessage.value.startsWith("failed:")) {
        sealStage.value = "idle";
        statusMessage.value = "";
    }
}

async function onTrigger() {
    if (sealStage.value !== "idle") return;
    sealStage.value = "signing";
    console.log("[seal] onTrigger fired");
    stopStt();

    try {
        statusMessage.value = "connecting to host…";
        const ok = await ensureHost();
        if (!ok) throw new Error("open this page inside the Polkadot app");

        statusMessage.value = "resolving identity…";
        const account = await resolveLegacyAccount();
        console.log(
            "[seal] legacy account",
            account.name,
            u8aToHex(account.publicKey),
        );

        // Snapshot the rolling chain and freeze talk metadata. The artifact below
        // is exactly what the verifier (proofoftalk/src/main.js) renders.
        const endedAt = new Date().toISOString();
        const started = startedAt.value ?? endedAt;
        const top = rawBeacons.value[0];
        anchorBlock.value = top?.number ?? 0;
        const chainSnapshot = chainLog.slice();
        const totalSentences = chainSnapshot.filter((e) => "s" in e).length;
        const totalBlocks = chainSnapshot.filter((e) => "h" in e).length;

        const unsignedArtifact: Record<string, unknown> = {
            v: 1,
            speaker: account.name,
            dotns: account.name,
            title: "People don't use technology, they use products",
            venue: "Web3 Summit 2026",
            started_at: started,
            ended_at: endedAt,
            window: formatWindow(started, endedAt),
            anchor_block: top ? top.number.toLocaleString() : "—",
            total_sentences: totalSentences,
            total_blocks: totalBlocks,
            chain: chainSnapshot,
        };
        const canonicalBytes = canonicalSigningBytes(unsignedArtifact);
        console.log(
            "[seal] canonical bytes",
            new TextDecoder().decode(canonicalBytes),
        );

        statusMessage.value = "signing with sr25519…";
        const signer = accountsProvider!.getLegacyAccountSigner(account);
        const sig = await signer.signBytes(canonicalBytes);

        statusMessage.value = `capturing at block #${anchorBlock.value.toLocaleString()}…`;
        const artifact = {
            ...unsignedArtifact,
            pubkey: u8aToHex(account.publicKey),
            sig: u8aToHex(sig),
            sig_alg: "sr25519",
        };
        const bytes = new TextEncoder().encode(JSON.stringify(artifact));
        const cid = cidForBytes(bytes);

        await ensureBulletinAllowance();
        await preimageManager!.submit(bytes);

        statusMessage.value = "sealed.";
        const url = `polkadotapp://proofoftalk.dot/#/${cid}`;
        qrDataUrl.value = await QRCode.toDataURL(url, {
            margin: 1,
            width: 720,
            color: { dark: "#0a0a0c", light: "#ffffff" },
            errorCorrectionLevel: "M",
        });
        sealStage.value = "sealed";
    } catch (err: any) {
        console.error("[seal] failed", err);
        statusMessage.value = `failed: ${err?.message ?? String(err)} (click panel to retry)`;
        // Stay in `signing` so the message stays visible. Tap the status line to reset.
    }
}

onMounted(() => {
    beaconTickTimer = setInterval(() => {
        nowTick.value = Date.now();
    }, 1000);
    connect();
});

onBeforeUnmount(() => {
    stopStt();
    if (beaconTickTimer) clearInterval(beaconTickTimer);
});
</script>

<style scoped>
.full {
    width: 100%;
    height: 100%;
    padding: 2.4rem 3.2rem 2rem;
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    color: #d8d8de;
    background: #000;
    display: flex;
    flex-direction: column;
    gap: 1.4rem;
}
.header {
    display: flex;
    justify-content: space-between;
    align-items: center;
}
.row {
    display: flex;
    align-items: center;
    gap: 0.65rem;
}
.title {
    font-family: "Inter", system-ui, sans-serif;
    letter-spacing: 0.32em;
    font-size: 0.7rem;
    font-weight: 500;
    color: rgba(255, 255, 255, 0.85);
    text-transform: uppercase;
}
.muted {
    color: rgba(200, 200, 210, 0.45);
}
.small {
    font-family: "Inter", system-ui, sans-serif;
    font-size: 0.55rem;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    font-weight: 500;
}
.mono {
    font-variant-numeric: tabular-nums;
}
.dot {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: #ffffff;
    box-shadow: 0 0 10px rgba(255, 255, 255, 0.55);
    animation: pulse 1.6s ease-in-out infinite;
    transition:
        background 0.3s ease,
        box-shadow 0.3s ease;
}
.dot.idle {
    background: rgba(255, 255, 255, 0.22);
    box-shadow: none;
    animation: none;
}

.grid {
    display: grid;
    grid-template-columns: 1.55fr 1fr;
    gap: 2.5rem;
    flex: 1;
    min-height: 0;
}
.col {
    display: flex;
    flex-direction: column;
    gap: 0.8rem;
    min-height: 0;
}

.transcript {
    font-family: "DM Serif Display", serif;
    font-size: 1.3rem;
    line-height: 1.55;
    color: rgba(220, 220, 230, 0.4);
    overflow: hidden;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}
.transcript .completed {
    margin: 0;
}
.transcript .live {
    margin: 0;
    color: #f4f4f7;
}
.caret {
    display: inline-block;
    width: 1px;
    height: 0.95em;
    background: rgba(255, 255, 255, 0.85);
    margin-left: 0.15em;
    vertical-align: -0.08em;
    animation: blink 1.05s steps(2) infinite;
}

.beacons {
    font-size: 0.65rem;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
}
.beacon {
    display: grid;
    grid-template-columns: 5.5rem 1fr 4.5rem;
    gap: 0.45rem;
    padding: 0.3rem 0.55rem;
    border: 1px solid rgba(255, 255, 255, 0.1);
    border-radius: 4px;
    background: rgba(255, 255, 255, 0.02);
}
.beacon:first-child {
    border-color: rgba(255, 255, 255, 0.28);
    background: rgba(255, 255, 255, 0.05);
}
.n {
    color: rgba(255, 255, 255, 0.8);
}
.h {
    color: rgba(220, 220, 230, 0.65);
}
.t {
    color: rgba(180, 180, 190, 0.42);
    text-align: right;
}

.footer {
    display: flex;
    justify-content: center;
    align-items: center;
    padding-top: 0.8rem;
}
.qr-trigger {
    display: inline-flex;
    align-items: center;
    gap: 0.7rem;
    padding: 0.7rem 1.4rem;
    background: transparent;
    border: 1px solid rgba(255, 255, 255, 0.32);
    border-radius: 999px;
    color: rgba(255, 255, 255, 0.92);
    font-family: "Inter", system-ui, sans-serif;
    font-size: 0.65rem;
    font-weight: 500;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    cursor: pointer;
    transition:
        border-color 0.18s ease,
        background 0.18s ease,
        color 0.18s ease;
}
.qr-trigger:hover {
    border-color: rgba(255, 255, 255, 0.7);
    background: rgba(255, 255, 255, 0.04);
}
.trigger-icon {
    font-size: 0.85rem;
    line-height: 1;
    color: rgba(255, 255, 255, 0.7);
}
.trigger-label {
    line-height: 1;
}

/* ---- Signing state ---- */
.signing {
    display: inline-flex;
    align-items: center;
    gap: 0.85rem;
    padding: 0.7rem 1.4rem;
    border: 1px solid rgba(255, 255, 255, 0.32);
    border-radius: 999px;
    color: rgba(255, 255, 255, 0.92);
    font-family: "Inter", system-ui, sans-serif;
    font-size: 0.65rem;
    font-weight: 500;
    letter-spacing: 0.32em;
    text-transform: uppercase;
    animation: signing-in 0.25s ease both;
}
.spinner {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    border: 1.5px solid rgba(255, 255, 255, 0.18);
    border-top-color: #ffffff;
    animation: spin 0.75s linear infinite;
}
.status {
    line-height: 1;
}

/* ---- Sealed receipt ---- */
.receipt {
    display: flex;
    align-items: center;
    gap: 1.4rem;
    animation: receipt-in 0.55s cubic-bezier(0.16, 0.84, 0.32, 1) both;
    transform-origin: center;
}
.qr-card {
    width: 6.4rem;
    height: 6.4rem;
    padding: 0.45rem;
    background: #ffffff;
    border-radius: 6px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow:
        0 0 36px rgba(255, 255, 255, 0.18),
        0 0 0 1px rgba(255, 255, 255, 0.5);
}

/* ---- Big right-column QR (post-seal) ---- */
.receipt-big {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1.1rem;
    flex: 1;
    min-height: 0;
    animation: receipt-in 0.55s cubic-bezier(0.16, 0.84, 0.32, 1) both;
    transform-origin: center;
}
.qr-card-big {
    flex: 1;
    width: 100%;
    max-width: 28rem;
    aspect-ratio: 1 / 1;
    padding: 0.9rem;
    background: #ffffff;
    border-radius: 10px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow:
        0 0 60px rgba(255, 255, 255, 0.22),
        0 0 0 1px rgba(255, 255, 255, 0.55);
}
.qr-card-big .qr-img {
    width: 100%;
    height: 100%;
}
.receipt-big .receipt-meta {
    text-align: center;
    align-items: center;
}
.qr-img {
    width: 100%;
    height: 100%;
    display: block;
    image-rendering: pixelated;
}
.receipt-meta {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    text-align: left;
}
.receipt-label {
    font-family: "Inter", system-ui, sans-serif;
    font-size: 0.55rem;
    font-weight: 500;
    letter-spacing: 0.4em;
    text-transform: uppercase;
    color: rgba(255, 255, 255, 0.55);
}
.receipt-line {
    font-family: "DM Serif Display", serif;
    font-style: italic;
    font-size: 0.95rem;
    color: rgba(255, 255, 255, 0.88);
}
.receipt-line.dim {
    color: rgba(255, 255, 255, 0.5);
    font-size: 0.8rem;
}
.receipt-line .mono {
    font-family: "JetBrains Mono", ui-monospace, monospace;
    font-style: normal;
    font-size: 0.85rem;
    color: #ffffff;
}

@keyframes spin {
    to {
        transform: rotate(360deg);
    }
}
@keyframes signing-in {
    from {
        opacity: 0;
        transform: translateY(2px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}
@keyframes receipt-in {
    0% {
        opacity: 0;
        transform: scale(0.18);
    }
    60% {
        opacity: 1;
    }
    100% {
        opacity: 1;
        transform: scale(1);
    }
}

@keyframes pulse {
    0%,
    100% {
        opacity: 1;
        transform: scale(1);
    }
    50% {
        opacity: 0.35;
        transform: scale(0.85);
    }
}
@keyframes blink {
    0%,
    49% {
        opacity: 1;
    }
    50%,
    100% {
        opacity: 0;
    }
}
</style>
