Message sliders & supporters overlay

Donation bar and old donation overlay removed.
New dropping/sliding message notification system.
Thank you page for sponsors & donors.
This commit is contained in:
Mononaut 2021-12-30 19:19:22 -06:00
parent 8db6fef727
commit e7dff7024e
19 changed files with 789 additions and 842 deletions

View File

@ -83,16 +83,16 @@ body {
}
a {
color: rgb(0,100,200);
color: var(--palette-x);
text-decoration: none;
}
a:hover {
text-decoration: underline;
text-decoration: none;
}
a:visited {
color: rgb(0,80,160);
color: inherit;
}
label {
@ -137,6 +137,23 @@ button:focus {
border-color: #666;
}
button.action-button {
background: var(--bold-a);
color: white;
padding: 0.5em 2em;
margin: 1em 2em 0.5em;
font-size: 1.1em;
font-weight: 600;
box-shadow: inset 0px 0px 5px var(--dark-b);
text-shadow: 0px 0px 5px var(--dark-b);
}
button.action-button:hover {
box-shadow: inset 0px 0px 2px var(--dark-b);
}
button.action-button:active {
box-shadow: none;
}
@keyframes spin {
from {transform:rotate(0deg);}
to {transform:rotate(360deg);}

View File

@ -1,269 +0,0 @@
<script>
import analytics from '../utils/analytics.js'
import { onMount } from 'svelte'
import Icon from '../components/Icon.svelte'
import config from '../config.js'
import { fly, fade } from 'svelte/transition'
// import { linear } from 'svelte/easing'
import coffeeIcon from '../assets/icon/cib-buy-me-a-coffee.svg'
import clipboardIcon from '../assets/icon/cil-clipboard.svg'
import qrIcon from '../assets/icon/cil-qr-code.svg'
import closeIcon from '../assets/icon/cil-x-circle.svg'
import openIcon from '../assets/icon/cil-arrow-circle-bottom.svg'
import boltIcon from '../assets/icon/cil-bolt-filled.svg'
import { overlay } from '../stores.js'
let copied = false
let addressElement
let showCopyButton = true
let expanded = false
let qrHidden = true
let qrLocked = false
let qrElement
onMount(() => {
showCopyButton = (navigator && navigator.clipboard && navigator.clipboard.writeText) || !!addressElement
})
async function copyAddress () {
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(config.donationAddress)
copied = true
setTimeout(() => {
copied = false
}, 2000)
} else if (addressElement) {
// fallback
const range = document.createRange()
range.selectNodeContents(addressElement)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
copied = document.execCommand('copy')
setTimeout(() => {
copied = false
selection.removeAllRanges()
}, 2000)
}
analytics.trackEvent('donations', 'on-chain', 'copy-address')
}
function showQR () {
qrHidden = false
}
function hideQR () {
qrHidden = true
}
function clickaway (event) {
if (!qrElement.contains(event.target)) {
if (qrLocked) analytics.trackEvent('donations', 'on-chain', 'hide-qr')
qrLocked = false
window.removeEventListener('click', clickaway)
}
}
function toggleQR () {
qrLocked = !qrLocked
window.addEventListener('click', clickaway)
analytics.trackEvent('donations', 'on-chain', qrLocked ? 'show-qr' : 'hide-qr')
}
function toggleExpanded () {
expanded = !expanded
analytics.trackEvent('donations', 'bar', expanded ? 'open' : 'close')
}
function openLightningOverlay () {
expanded = false
analytics.trackEvent('donations', 'bar', 'close')
$overlay = 'lightning'
}
</script>
<style type="text/scss">
.donation-bar {
z-index: 100;
position: absolute;
top: 0;
width: 32rem;
min-width: 100px;
max-width: calc(100vw - 8.25rem);
margin: auto;
transition: width 300ms, max-width 300ms;
.open-close-button {
position: absolute;
font-size: 1.2rem;
bottom: -0.5em;
right: 1rem;
transform: rotate(0deg);
background: var(--palette-c);
border-radius: 50%;
color: var(--palette-x);
cursor: pointer;
transition: transform 600ms;
}
.donation-content {
padding: 5px 5px;
background: var(--palette-c);
color: var(--palette-x);
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
.main-content {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.coffee-icon {
font-size: 2rem;
}
.address-and-copy {
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 1;
min-width: 4em;
}
.address {
font-family: monospace;
font-weight: bold;
margin: 0 5px;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-button, .qr-button {
position: relative;
margin: 0 .25em;
font-size: 1.5rem;
cursor: pointer;
.copy-notif {
font-size: .7rem;
color: var(--palette-x);
position: absolute;
top: 100%;
right: 0;
background: var(--palette-d);
border: solid 1px var(--palette-x);
padding: 2px;
}
}
}
.donation-info {
text-align: justify;
}
.expandable-content {
max-height: 0;
transition: max-height 300ms;
overflow: hidden;
padding: 0 0.5em 0.5em;
font-size: 0.8em;
.lightning-button {
background: var(--bold-a);
color: white;
padding: 5px 8px;
}
}
}
.address-qr {
display: block;
position: absolute;
top: 100%;
left: 0;
right: 0;
margin: auto;
max-width: 100vw;
max-height: calc(100vh - 120px);
}
@media (min-width: 611px) {
left: 110px;
right: 110px;
}
@media (max-width: 610px) {
right: 0;
}
&.expanded {
max-width: 100vw;
.open-close-button {
transform: rotate(180deg);
}
.donation-content {
.main-content {
.address-and-copy {
.address {
word-break: break-all;
overflow: visible;
text-align: left;
}
}
}
.expandable-content {
max-height: 200px;
}
}
}
}
</style>
<div class="donation-bar" transition:fly={{ y: -10 }} class:expanded>
<div class="donation-content">
<div class="main-content">
<span class="coffee-icon">
<Icon icon={coffeeIcon} color="var(--bold-a)" inline />
</span>
<div class="address-and-copy">
<span class="address" bind:this={addressElement}>{ config.donationAddress }</span>
{#if showCopyButton}
<button class="copy-button" on:click={copyAddress} title="Copy address" alt="Copy address">
<Icon icon={clipboardIcon} color="var(--palette-x)" />
{#if copied }
<span class="copy-notif" transition:fly={{ y: -5 }}>Copied to clipboard!</span>
{/if}
</button>
{/if}
<div class="qr-button" title="Show QR" on:pointerover={showQR} on:pointerenter={showQR} on:pointerleave={hideQR} on:pointerout={hideQR} on:pointercancel={hideQR} on:click={toggleQR} bind:this={qrElement}>
<Icon icon={qrIcon} color="var(--palette-x)" />
</div>
</div>
</div>
<div class="expandable-content">
<p class="donation-info">
Enjoying Bitfeed? Donations keep this site running. On-chain transactions to the donation address above appear highlighted in green.
</p>
{#if config.lightningEnabled }
<button class="lightning-button" on:click={openLightningOverlay} >
<Icon icon={boltIcon} color="white" inline />Prefer Lightning?
</button>
{/if}
</div>
</div>
{#if !qrHidden || qrLocked}
<img src="/img/qr.png" alt="" class="address-qr" transition:fade={{ duration: 300 }} >
{/if}
<div class="open-close-button" on:click={toggleExpanded}>
<Icon icon={openIcon} color="var(--palette-x)" />
</div>
</div>

View File

@ -17,7 +17,7 @@ import clipboardIcon from '../assets/icon/cil-clipboard.svg'
import twitterIcon from '../assets/icon/cib-twitter.svg'
import { fade, fly } from 'svelte/transition'
import { durationFormat } from '../utils/format.js'
import { overlay } from '../stores.js'
import { overlay, tiers } from '../stores.js'
import QRCode from 'qrcode'
let tab = 'form' // form | invoice | success
@ -50,12 +50,11 @@ const invoiceHexPlaceholder = 'lnbcxxxxxxt8l4pp5umz5kyakc0u8z3w2y568entyyq2gafgc
let qrSrc = null
let invoicePayments = []
let tierThresholds
let tiers = []
let tierThresholds = []
$: {
if (tierThresholds) {
tiers = [
if ($tiers) {
tierThresholds = [
{
title: 'Supporter',
description: "Help to keep the lights on with a small donation",
@ -64,26 +63,26 @@ $: {
optional: true,
min: 0.00005000,
minSats: 5000,
max: tierThresholds.hero.min,
maxSats: btcToSats(tierThresholds.hero.min),
max: $tiers.hero.min,
maxSats: btcToSats($tiers.hero.min),
},
{
title: 'Community Hero',
description: "Add your Twitter profile to our Heroes Hall of Fame",
description: "Add your Twitter profile to our Supporters page",
emoji: '🦸',
color: 'var(--bold-c)',
min: tierThresholds.hero.min,
minSats: btcToSats(tierThresholds.hero.min),
max: tierThresholds.sponsor.min,
maxSats: btcToSats(tierThresholds.sponsor.min),
min: $tiers.hero.min,
minSats: btcToSats($tiers.hero.min),
max: $tiers.sponsor.min,
maxSats: btcToSats($tiers.sponsor.min),
},
{
title: 'Enterprise Sponsor',
description: "Display your logo on Bitfeed, with a link to your website",
emoji: '🕴️',
color: 'var(--bold-a)',
min: tierThresholds.sponsor.min,
minSats: btcToSats(tierThresholds.sponsor.min),
min: $tiers.sponsor.min,
minSats: btcToSats($tiers.sponsor.min),
max: Infinity,
maxSats: Infinity,
}
@ -207,19 +206,8 @@ onMount(() => {
console.log('error loading/parsing invoice')
}
}
loadTiers()
})
async function loadTiers () {
try {
const r = await fetch(`${config.donationRoot}/api/sponsorship/tiers.json`)
tierThresholds = await r.json()
} catch (e) {
console.log('failed to load sponsorship tiers')
console.log(e)
}
}
function resetInvoice () {
invoicePaid = false
invoiceExpired = false
@ -426,7 +414,7 @@ async function copyInvoice () {
}
</script>
<Overlay name="donation" fullHeight>
<Overlay name="donation" fullSize>
<section class="donation-modal">
<div class="tab-nav">
<button class="to left" class:disabled={!canTabLeft} on:click={tabLeft}>&larr;</button>
@ -447,7 +435,7 @@ async function copyInvoice () {
</p>
<div class="support-tiers">
{#each tiers as tier}
{#each tierThresholds as tier}
<TierCard {...tier} active={btc >= tier.min && btc < tier.max} on:click={() => { setAmount(tier.min || 0.00005000, true) }} />
{/each}
</div>
@ -456,7 +444,7 @@ async function copyInvoice () {
<div class="choose-amount">
<div class="amount-slider">
<SatoshiSlider value={sats} max={btcToSats(1)} thresholds={tiers} logScale on:input={(e) => { setAmount(e.detail)}} />
<SatoshiSlider value={sats} max={btcToSats(1)} thresholds={tierThresholds} logScale on:input={(e) => { setAmount(e.detail)}} />
</div>
<div class="amount-input">
{#if unit === 'sats'}
@ -470,8 +458,8 @@ async function copyInvoice () {
</div>
</div>
{#if tierThresholds && btc >= tierThresholds.hero.min}
{#if btc >= tierThresholds.sponsor.min}
{#if $tiers && btc >= $tiers.hero.min}
{#if btc >= $tiers.sponsor.min}
<p class="credit-info">
Enter your email or twitter handle so we can reach you to say thanks and confirm sponsorship details! Or leave these fields blank to donate anonymously.
</p>
@ -491,7 +479,7 @@ async function copyInvoice () {
<input id="twitterHandle" type="text" bind:value={twitter}>
</div>
</div>
{#if btc >= tierThresholds.sponsor.min}
{#if btc >= $tiers.sponsor.min}
<div class="field">
<label for="twitterHandle">Email</label>
<div class="text-input email-input">
@ -752,14 +740,6 @@ async function copyInvoice () {
}
}
.action-button {
background: var(--bold-a);
color: white;
padding: 0.5em 2em;
margin: 1em 2em 0.5em;
font-size: 1.1em;
}
.invoice-area {
display: flex;
flex-direction: row;

View File

@ -1,359 +0,0 @@
<script>
import analytics from '../utils/analytics.js'
import config from '../config.js'
import { onMount } from 'svelte'
import Overlay from '../components/Overlay.svelte'
import Icon from '../components/Icon.svelte'
import boltIcon from '../assets/icon/cil-bolt-filled.svg'
import tickIcon from '../assets/icon/cil-check-alt.svg'
import timerIcon from '../assets/icon/cil-av-timer.svg'
import { fade } from 'svelte/transition'
import { durationFormat } from '../utils/format.js'
import { overlay } from '../stores.js'
import QRCode from 'qrcode'
let amount = 5000
let invoice = null
let invoicePaid = false
let invoiceExpired = false
let invoicePoll
let pollingEnabled = false
let invoiceAmountLabel
let invoiceExpiryLabel
let invoiceHexLabel
const invoiceHexPlaceholder = 'lnbcxxxxxxt8l4pp5umz5kyakc0u8z3w2y568entyyq2gafgc3n5a7khdtk5m9fehkxnqdq6gf5hgen9v4jzqer0deshg6t0dccqzpgxqzjcsp5arlylwgraa2u75g4wh40swvxyvt0cpyrmnl4cha40uj5x2fr0t8q9qy9qsqzh3dtfag0ymaf8dpyxrly9p04jwlgdaxkh6g9ysaxyzz7jtrrkpsxv52mlzl6wgn6l6eur9yrl5q2quh5p8kagmng45gqjz9e2c6uxgqx5ezjr'
let qrSrc = null
$: {
if (invoice && invoice.id && invoice.amount && invoice.BOLT11) {
invoiceAmountLabel = `${Number.parseInt(invoice.amount) / 1000} sats`
invoiceHexLabel = invoice.BOLT11
setQR(invoice.BOLT11)
} else {
stopExpiryTimer()
invoiceAmountLabel = `${amount || 5000} sats`
invoiceHexLabel = invoiceHexPlaceholder
qrSrc = null
}
}
async function setQR(invoice) {
try {
qrSrc = await QRCode.toDataURL(invoice.toUpperCase())
} catch (err) {
console.log('error generating QR code: ', err)
}
}
let expiryTick
let expiresIn = null // time till expiry in seconds
$: {
invoiceExpiryLabel = expiresIn == null ? '' : durationFormat.format(expiresIn * 1000)
}
$: {
if ($overlay === 'lightning') {
startExpiryTimer()
stopPollingInvoice()
pollingEnabled = true
pollInvoice()
} else {
stopExpiryTimer()
stopPollingInvoice()
checkResetInvoice()
}
}
function expiryTimer () {
if (invoice && invoice.expiresAt) expiresIn = Math.round(((invoice.expiresAt * 1000) - 500000 - Date.now()) / 1000)
else expiresIn = null
}
function stopExpiryTimer () {
if (expiryTick) clearInterval(expiryTick)
expiryTick = null
}
function startExpiryTimer () {
if (!expiryTick && $overlay === 'lightning' && invoice && invoice.id) {
expiryTick = setInterval(expiryTimer, 200)
}
}
onMount(() => {
// check for existing invoice in local storage:
const loadedInvoiceJSON = localStorage.getItem(`lightning-invoice`)
localStorage.removeItem('lightning-invoice')
if (loadedInvoiceJSON) {
try {
const loadedInvoice = JSON.parse(loadedInvoiceJSON)
if (loadedInvoice && loadedInvoice.id && loadedInvoice.status && loadedInvoice.status === 'Unpaid' && (loadedInvoice.expiresAt * 1000) > Date.now()) {
invoice = loadedInvoice
processInvoice()
}
} catch (err) {
console.log('error loading/parsing invoice')
}
}
})
function resetInvoice () {
invoicePaid = false
invoiceExpired = false
invoice = null
qrSrc = null
}
function checkResetInvoice () {
if (invoice && invoice.status !== 'Unpaid') resetInvoice()
}
function stopPollingInvoice() {
pollingEnabled = false
if (invoicePoll) clearTimeout(invoicePoll)
invoicePoll = null
}
function processInvoice () {
if (invoice) {
startExpiryTimer()
if (invoice.status === 'Paid') {
invoicePaid = true
invoiceExpired = false
analytics.trackEvent('donations', 'lightning', 'paid', invoice.amount / 1000)
localStorage.removeItem('lightning-invoice')
} else if (invoice.status === 'Expired' || (invoice.expiresAt * 1000) - 500000 < Date.now()) {
invoicePaid = false
invoiceExpired = true
localStorage.removeItem('lightning-invoice')
} else if (invoice.status === 'Unpaid') {
invoicePaid = false
invoiceExpired = false
localStorage.setItem('lightning-invoice', JSON.stringify(invoice))
invoicePoll = setTimeout(pollInvoice, 2000)
}
}
}
async function pollInvoice () {
if (pollingEnabled && invoice && invoice.status === 'Unpaid') {
const response = await fetch(`${config.lightningRoot}/api/lightning/invoice/${invoice.id}`, {
method: 'GET'
})
invoice = await response.json()
processInvoice()
}
}
async function generateInvoice () {
if (amount) {
analytics.trackEvent('donations', 'lightning', 'generate', amount)
resetInvoice()
const response = await fetch(`${config.lightningRoot}/api/lightning/invoice`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ amount })
})
invoice = await response.json()
if (invoice && invoice.amount) analytics.trackEvent('donations', 'lightning', 'generate-success', invoice.amount / 1000)
processInvoice()
}
}
</script>
<style type="text/scss">
.info {
p {
text-align: justify;
}
.lightning-form {
.sats-input {
position: relative;
display: inline-block;
.units-label {
position: absolute;
top: 0;
right: 1.4em;
bottom: 0;
text-align: right;
color: var(--dark-c);
top: 50%;
transform: translateY(-50%);
}
input {
margin: 0;
}
}
.lightning-button {
background: var(--bold-a);
color: white;
padding: 5px 8px;
margin: .5em;
}
}
.invoice-area {
display: flex;
flex-direction: row;
align-items: stretch;
flex-wrap: wrap;
}
.invoice-info {
position: relative;
padding: 10px;
width: 280px;
min-width: 200px;
margin: .5em;
flex: 1;
.field {
word-break: break-all;
visibility: hidden;
.field-label {
font-weight: bold;
}
.hex {
font-family: monospace;
}
}
.placeholder-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 10px;
background: var(--dark-d);
display: flex;
justify-content: center;
align-items: center;
.placeholder-label {
color: var(--light-a);
opacity: 0.5;
}
}
&.ready {
.field {
visibility: visible;
}
}
}
.qr-container {
position: relative;
flex: 1;
width: 280px;
min-width: 200px;
min-height: 240px;
margin: .5em;
border-radius: 10px;
background: var(--dark-d);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.invoice-status {
font-weight: bold;
font-size: 1.6em;
color: white;
}
.invoice-icon {
font-size: 8em;
}
.qr-image-wrapper {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.qr-image {
width: 90%;
height: 90%;
object-fit: contain;
object-position: center;
}
}
&.paid {
background: var(--light-good);
}
&.expired {
background: var(--light-unsure);
}
}
}
</style>
<Overlay name="lightning">
<section class="info">
<h2>Donate with Lightning</h2>
<p>
Enter the amount you would like to donate, then click the button to generate a payment request:
</p>
<div class="lightning-form">
<div class="sats-input">
<input type="number" bind:value={amount}>
<span class="units-label">sats</span>
</div>
<button class="lightning-button" on:click={generateInvoice} >
<Icon icon={boltIcon} color="white" inline />
{#if invoice && invoice.id }
Generate New Request
{:else}
Generate Payment Request
{/if}
</button>
</div>
<div class="invoice-area">
<div class="invoice-info" class:ready={invoice && invoice.id}>
<p class="field invoice-amount"><span class="field-label">Amount:</span> { invoiceAmountLabel }</p>
<p class="field invoice-expires"><span class="field-label">Expiry:</span> { invoiceExpiryLabel }</p>
<p class="field invoice"><span class="field-label">Payment request:</span> <span class="hex">{ invoiceHexLabel }</span></p>
{#if !invoice || !invoice.id }
<div class="placeholder-overlay" transition:fade={{ duration: 300 }}>
<p class="placeholder-label">Payment Request</p>
</div>
{/if}
</div>
<div class="qr-container" class:paid={invoicePaid} class:expired={invoiceExpired}>
{#if invoicePaid }
<div class="invoice-icon"><Icon icon={tickIcon} color="white" /></div>
<h3 class="invoice-status">Received, Thanks!</h3>
{:else if invoiceExpired}
<div class="invoice-icon"><Icon icon={timerIcon} color="white" /></div>
<h3 class="invoice-status">Invoice Expired</h3>
{:else}
<div class="invoice-icon"><Icon icon={boltIcon} color="white" /></div>
{/if}
{#if qrSrc && !invoicePaid && !invoiceExpired}
<div class="qr-image-wrapper">
<img src={qrSrc} class="qr-image" alt="invoice qr code">
</div>
{/if}
</div>
</div>
</section>
</Overlay>

View File

@ -13,6 +13,19 @@
padding-bottom: 100%;
}
.mononaut .backdrop {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
background: black;
border-radius: 50%;
overflow: hidden;
}
.mononaut .inner {
position: absolute;
top: 0;
@ -25,7 +38,6 @@
justify-content: center;
align-items: center;
border-radius: 50%;
background: linear-gradient(45deg, darkviolet, transparent 100%);
}
@ -93,6 +105,7 @@
<div class="mononaut">
<div class="aspect">
<div class="backdrop">
<div class="inner">
<svg class="avatar"
width="334.47833"
@ -237,4 +250,5 @@
</svg>
</div>
</div>
</div>
</div>

View File

@ -8,7 +8,7 @@ import { fade, fly } from 'svelte/transition'
const dispatch = createEventDispatcher()
export let name = 'none'
export let fullHeight = false
export let fullSize = false
let open
$: {
@ -89,10 +89,12 @@ function close () {
max-width: 95%;
}
&.full-height {
&.full-size {
height: 100%;
width: 100%;
.overlay-inner {
height: 100%;
width: 100%;
}
}
}
@ -102,7 +104,7 @@ function close () {
{#if open}
<div class="overlay-wrapper" >
<div class="overlay-background" on:click={close} transition:fade={{ duration: 500 }} />
<div class="overlay-outer" class:full-height={fullHeight} transition:fly={{ duration: 500, y: 50 }}>
<div class="overlay-outer" class:full-size={fullSize} transition:fly={{ duration: 500, y: 50 }}>
<div class="overlay-inner" id="{name}Overlay">
<slot />
</div>

View File

@ -18,8 +18,8 @@ let settingConfig = {
showFPS: {
label: 'FPS'
},
showDonation: {
label: 'Donation Info'
showMessages: {
label: 'Message Bar'
},
vbytes: {
label: 'Size by',

View File

@ -13,6 +13,7 @@ import questionIcon from '../assets/icon/help-circle.svg'
import infoIcon from '../assets/icon/info.svg'
import atIcon from '../assets/icon/cil-at.svg'
import gridIcon from '../assets/icon/grid-icon.svg'
import peopleIcon from '../assets/icon/cil-people.svg'
import MempoolLegend from '../components/MempoolLegend.svelte'
import ContactTab from '../components/ContactTab.svelte'
@ -35,6 +36,10 @@ function openAbout () {
$overlay = 'about'
}
function openSupporters () {
$overlay = 'supporters'
}
function showBlock () {
analytics.trackEvent('viz', 'block', 'show')
$blockVisible = true
@ -83,6 +88,11 @@ function showBlock () {
<Icon icon={questionIcon} color="var(--bold-a)" />
</span>
</SidebarTab>
<SidebarTab on:click={openSupporters} tooltip="Supporters">
<span slot="tab">
<Icon icon={peopleIcon} color="var(--bold-a)" />
</span>
</SidebarTab>
{#if config.dev && config.debug}
<SidebarTab open={$sidebarToggle === 'dev'} on:click={() => {settings('dev')}} tooltip="Debug">
<span slot="tab">

View File

@ -0,0 +1,126 @@
<script>
import config from '../config.js'
import { onMount } from 'svelte'
import Overlay from '../components/Overlay.svelte'
import { overlay, tiers, sponsors, heroes } from '../stores.js'
let displayHeroes = []
$: {
if ($heroes) {
displayHeroes = Object.values($heroes).filter(hero => {
return hero && hero.id && hero.img_ext
}).map(hero => {
return {
...hero,
img: `${config.donationRoot}/img/avatar/${hero.id}${hero.img_ext}`
}
})
}
}
</script>
<Overlay name="supporters" fullSize>
<section class="supporters-modal">
<h2>Our Supporters</h2>
<p class="info">
Bitfeed is only possible thanks to the generosity of our supporters:
</p>
{#if $sponsors && $sponsors.length}
<div class="group">
<h3>Enterprise Sponsors</h3>
<div class="entries">
{#each $sponsors as sponsor}
<a class="supporter" target="_blank" href={sponsor.website}>
<img src={sponsor.img} alt={sponsor.name}>
<span class="label">{ sponsor.name }</span>
</a>
{/each}
</div>
</div>
<button class="action-button" on:click={() => { $overlay = 'donation' }}>
Become a Sponsor!
</button>
{/if}
{#if $heroes && displayHeroes.length}
<div class="group">
<h3>Community Heroes</h3>
<div class="entries">
{#each displayHeroes as hero}
<a class="supporter hero" target="_blank" href="https://twitter.com/{hero.username}">
<img src={hero.img} alt={hero.name}>
<span class="label">@{ hero.username }</span>
</a>
{/each}
</div>
</div>
<button class="action-button" on:click={() => { $overlay = 'donation' }}>
Become a Community Hero!
</button>
{/if}
</section>
</Overlay>
<style type="text/scss">
.supporters-modal {
font-size: 0.9rem;
width: 100%;
p.info {
text-align: center;
margin: 0 0 .25em;
}
h3 {
margin-top: 2em;
}
.group {
margin-bottom: 2em;
.entries {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
.supporter {
width: 120px;
margin: 10px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
img {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: solid 3px transparent;
transition: border 200ms;
}
&:hover {
img {
border: solid 3px var(--bold-a);
}
}
&.hero {
&:hover {
img {
border: solid 3px var(--bold-b);
}
}
.label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.8em;
}
}
}
}
}
}
</style>

View File

@ -10,7 +10,8 @@
import Sidebar from '../components/Sidebar.svelte'
import AboutOverlay from '../components/AboutOverlay.svelte'
import DonationOverlay from '../components/DonationOverlay.svelte'
import DonationBar from '../components/DonationBar.svelte'
import SupportersOverlay from '../components/SupportersOverlay.svelte'
import Alerts from '../components/alert/Alerts.svelte'
import { integerFormat } from '../utils/format.js'
import { exchangeRates, localCurrency, lastBlockId } from '../stores.js'
import { formatCurrency } from '../utils/fx.js'
@ -69,7 +70,7 @@
$devEvents.addManyCallback = fakeTxs
$devEvents.addBlockCallback = fakeBlock
if (!$settings.showDonation) $settings.showDonation = true
if (!$settings.showMessages) $settings.showMessages = true
})
function resize () {
@ -437,17 +438,18 @@
{/if}
</div>
</div>
{#if $settings.showDonation }
<DonationBar />
{/if}
<div class="spacer" />
{#if $settings.showMessages }
<Alerts />
{/if}
</div>
<Sidebar />
<AboutOverlay />
{#if config.lightningEnabled }
{#if config.donationsEnabled }
<DonationOverlay />
<SupportersOverlay />
{/if}
{#if config.dev && config.debug && $devSettings.guides }

View File

@ -0,0 +1,199 @@
<script>
import { onMount } from 'svelte'
import { alerts, heroes, sponsors, overlay, sidebarToggle } from '../../stores.js'
import config from '../../config.js'
import ByMononaut from './ByMononaut.svelte'
import HeroMsg from './Hero.svelte'
import SponsoredMsg from './Sponsored.svelte'
import GenericAlert from './GenericAlert.svelte'
import { fly } from 'svelte/transition'
const components = {
mononaut: ByMononaut,
"sponsored-by": SponsoredMsg,
// "be-a-hero": BeAHero,
"thank-you-hero": HeroMsg,
msg: GenericAlert
}
const actions = {
support: () => {
$overlay = 'donation'
},
supporters: () => {
$overlay = 'supporters'
},
contact: () => {
$sidebarToggle = 'contact'
}
}
const sequences = {}
let rotating = false
let processedAlerts = []
$: {
if ($alerts) {
processedAlerts = $alerts.map(processAlert).filter(alert => alert != null)
startAlerts()
}
}
function processAlert (alert) {
if (alert && alert.type && components[alert.type]) {
if (!sequences[alert.key]) sequences[alert.key] = 0
return {
...alert,
component: components[alert.type],
action: actions[alert.action] || null
}
} else return null
}
let activeAlerts = [{ key: 'null1' }, { key: 'null2' }]
let lastIndex = -1
onMount(() => {
startAlerts()
})
function startAlerts () {
if (!rotating && processedAlerts && processedAlerts.length) {
rotating = true
activeAlerts[0] = processedAlerts[0] || { key: 'null1' }
activeAlerts[1] = processedAlerts[1] || { key: 'null2' }
lastIndex = processedAlerts[1] ? 1 : 0
if (rotateTimer) clearTimeout(rotateTimer)
rotateTimer = setTimeout(rotateAlerts, config.alertDuration)
}
}
let rotateTimer
function rotateAlerts () {
if (rotateTimer) clearTimeout(rotateTimer)
if (processedAlerts && processedAlerts.length > 2) {
// find the next alert in the queue
let currentIndex = -1
if (activeAlerts[1]) {
currentIndex = processedAlerts.findIndex(alert => { alert.key === activeAlerts[1].key})
}
if (currentIndex < 0) currentIndex = lastIndex
currentIndex = (currentIndex + 1) % processedAlerts.length
// roll over to the next alert if there's a key clash
if (processedAlerts[currentIndex].key === activeAlerts[1].key) {
currentIndex = (currentIndex + 1) % processedAlerts.length
}
lastIndex = currentIndex
let nextAlert = processedAlerts[currentIndex]
if (nextAlert)
activeAlerts[0] = activeAlerts[1]
activeAlerts[1] = { key: 'temp' }
setTimeout(() => {
activeAlerts[1] = nextAlert
sequences[alert.key]++
}, 1000)
} else if (processedAlerts) {
activeAlerts[0] = processedAlerts[0] || { key: 'null1' }
activeAlerts[1] = processedAlerts[1] || { key: 'null2' }
}
rotateTimer = setTimeout(rotateAlerts, config.alertDuration)
}
</script>
<div class="alert-bar" transition:fly={{ y: -100 }}>
{#each activeAlerts as alert (alert.key)}
<div class="alert-wrapper" in:fly|local={{ y: -100 }} out:fly|local={{ x: 400}}>
{#if alert && alert.component }
{#if alert.href}
<a class="alert link" target="_blank" rel="noopener" href={alert.href}>
<svelte:component this={alert.component} {...alert} sequence={sequences[alert.key]} />
</a>
{:else if alert.action}
<div class="alert action" on:click={alert.action}>
<svelte:component this={alert.component} {...alert} sequence={sequences[alert.key]} />
</div>
{:else}
<div class="alert">
<svelte:component this={alert.component} {...alert} sequence={sequences[alert.key]} />
</div>
{/if}
{/if}
</div>
{/each}
</div>
<style type="text/scss">
.alert-bar {
position: relative;
width: 100%;
flex-grow: 1;
flex-shrink: 1;
height: 3.5em;
.alert-wrapper {
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 20em;
transform: translateX(-110%);
transition: transform 500ms ease-in-out;
.alert {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
width: 100%;
height: 100%;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
overflow: hidden;
cursor: pointer;
background: var(--palette-c);
transition: background 200ms;
&:hover {
background: var(--palette-d);
}
:global(.alert-content) {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: .5em 1em;
color: var(--palette-x);
}
}
&:first-child {
transform: translateX(0%);
}
}
@media screen and (max-width: 850px) {
.alert-wrapper {
transform: translateX(0);
&:first-child {
transform: translateX(110%);
}
}
}
@media screen and (max-width: 480px) {
height: 3em;
.alert-wrapper {
font-size: 0.8em;
width: 16em;
}
}
}
</style>

View File

@ -0,0 +1,41 @@
<script>
import Mononaut from '../Mononaut.svelte'
</script>
<div class="alert-content by-mononaut">
<h3>Bitfeed</h3>
<p class="by">by mononaut</p>
<div class="monkey-avatar">
<Mononaut />
</div>
</div>
<style type="text/scss">
.by-mononaut {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
h3 {
text-align: center;
flex-grow: 1;
}
.by {
margin-left: 10px;
}
.monkey-avatar {
width: 3em;
margin-left: 10px;
}
@media screen and (max-width: 480px) {
.by {
margin-left: 5px;
}
}
}
</style>

View File

@ -0,0 +1,51 @@
<script>
export let msg
export let shortmsg = null
export let img
</script>
<div class="alert-content generic-alert">
<p class="msg">
{@html msg }
</p>
<p class="shortmsg">
{@html shortmsg || msg }
</p>
{#if img}
<img src={img} alt="">
{/if}
</div>
<style>
.generic-alert {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
}
.msg, .shortmsg {
margin: 0;
font-size: .9em;
width: 100%;
text-align: center;
max-height: 100%;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.shortmsg {
display: none;
}
img {
height: 2.8em;
}
@media screen and (max-width: 480px) {
.shortmsg {
display: inline;
}
.msg {
display: none;
}
}
</style>

View File

@ -0,0 +1,83 @@
<script>
import { onMount } from 'svelte'
import config from '../../config.js'
import { heroes } from '../../stores.js'
let displayHeroes = []
function chooseRandomHeroes () {
displayHeroes = []
const validHeroes = Object.values($heroes).filter(hero => {
return hero && hero.id && hero.img_ext
})
const randomIndex = Math.floor(Math.random() * validHeroes.length)
for (let i = 0; i < Math.min(3, validHeroes.length); i++) {
const randomHero = validHeroes[(randomIndex + i) % validHeroes.length]
displayHeroes.push({
...randomHero,
img: `${config.donationRoot}/img/avatar/${randomHero.id}${randomHero.img_ext}`
})
}
}
$: {
if ($heroes && !displayHeroes.length) {
chooseRandomHeroes()
}
}
</script>
<div class="alert-content">
<p class="msg">Thank you to all of our Community Heroes!</p>
<div class="heros">
{#each displayHeroes as hero}
<img src={hero.img} alt={hero.username} title={hero.name} class="hero">
{/each}
</div>
</div>
<style type="text/scss">
.alert-content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
color: var(--palette-x);
.msg {
font-size: 0.9em;
}
.heros {
height: 2.5em;
width: 7.5em;
margin-left: 10px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
flex-shrink: 0;
.hero {
width: 2.3em;
height: 2.3em;
margin: 0 0.1em;
border-radius: 50%;
object-fit: contain;
}
}
@media screen and (max-width: 480px) {
.heros {
max-width: 5em;
.hero:nth-child(3) {
display: none;
}
}
}
}
</style>

View File

@ -0,0 +1,38 @@
<script>
export let img
export let name
</script>
<div class="alert-content sponsored-by">
<p class="msg">Sponsored by</p>
<h3>{ name }</h3>
{#if img}
<img src={img} alt="name" class="logo">
{/if}
</div>
<style type="text/scss">
.sponsored-by {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
color: var(--palette-x);
h3 {
text-align: center;
margin: 0 10px;
flex-grow: 1;
flex-shrink: 1;
}
.msg {
white-space: nowrap;
}
.logo {
height: 3em;
}
}
</style>

View File

@ -6,10 +6,8 @@ export default {
fps: true,
websocket_path: '/ws/txs',
localSocket: false,
nofeed: true,
nofeed: false,
txDelay: 10000,
blockTimeout: 10000,
donationAddress: "bc1qthanksv78zs5jnmysvmuuuzj09aklf8jmm49xl",
donationHash: "5dfb3b419e38a1494f648337ce7052797b6fa4f2",
lightningEnabled: true
donationsEnabled: true,
alertDuration: 20000,
}

View File

@ -20,14 +20,15 @@ export default class BitcoinTx {
this.time = time
if (config.donationHash && this.outputs) {
this.outputs.forEach(output => {
if (output.script_pub_key.includes(config.donationHash)) {
console.log('donation!', this)
this.highlight = true
}
})
}
// Highlight transactions to the static donation address
// if (config.donationHash && this.outputs) {
// this.outputs.forEach(output => {
// if (output.script_pub_key.includes(config.donationHash)) {
// console.log('donation!', this)
// this.highlight = true
// }
// })
// }
// is a coinbase transaction?
if (this.inputs && this.inputs.length === 1 && this.inputs[0].prev_txid === "0000000000000000000000000000000000000000000000000000000000000000") {

View File

@ -1,5 +1,5 @@
import { writable, derived } from 'svelte/store'
export { exchangeRates } from './utils/pollStore.js'
import { makePollStore } from './utils/pollStore.js'
import { symbols } from './utils/fx.js'
import LocaleCurrency from 'locale-currency'
import config from './config.js'
@ -42,6 +42,16 @@ function createCachedDict (namespace, defaultValues) {
}
}
// refresh exchange rates every minute
export const exchangeRates = makePollStore('rates', 'https://blockchain.info/ticker', 60000, {})
// refresh messages from donation server every hour
// export const alerts = makePollStore('alerts', `${config.donationRoot}/api/sponsorship/msgs.json`, 3600000, [])
export const alerts = makePollStore('alerts', `${config.donationRoot}/api/sponsorship/msgs.json`, 10000, [])
// refresh sponsor data every hour
export const heroes = makePollStore('heroes', `${config.donationRoot}/api/sponsorship/heroes.json`, 3600000, null)
export const sponsors = makePollStore('sponsors', `${config.donationRoot}/api/sponsorship/sponsors.json`, 3600000, null)
export const tiers = makePollStore('tiers', `${config.donationRoot}/api/sponsorship/tiers.json`, 3600000, null)
export const darkMode = writable(true)
export const serverConnected = writable(false)
export const serverDelay = writable(1000)
@ -73,7 +83,7 @@ export const settings = createCachedDict('settings', {
showFX: true,
vbytes: false,
fancyGraphics: true,
showDonation: true,
showMessages: true,
noTrack: false
})
@ -88,7 +98,7 @@ export const nativeAntialias = writable(false)
const newVisitor = !localStorage.getItem('seen-welcome-msg')
// export const overlay = writable(newVisitor ? 'about' : null)
export const overlay = writable('donation')
export const overlay = writable(null)
let currencyCode = LocaleCurrency.getCurrency(navigator.language)
console.log('LOCALE: ', navigator.language, currencyCode)

View File

@ -1,19 +1,24 @@
import { writable } from 'svelte/store'
function makeRatePollStore () {
export function makePollStore (name, url, frequency, initialValue={}, responseHandler) {
let timer
const { subscribe, set, update } = writable({})
const { subscribe, set, update } = writable(initialValue)
if (!responseHandler) responseHandler = async (response, set) => {
try {
const data = await response.json()
if (data) set(data)
} catch (error) {
console.log(`failed to parse polled data for ${name}: `, error)
}
}
const fetcher = () => {
fetch(`https://blockchain.info/ticker?t=${Date.now()}`).then(async response => {
const rates = await response.json()
set(rates)
}).catch(err => {
console.log('error fetching exchange rates: ', err)
fetch(`${url}?t=${Date.now()}`).then(response => { responseHandler(response, set) }).catch(err => {
console.log(`error polling data for ${name}: `, error)
})
}
fetcher()
timer = setInterval(fetcher, 60000)
timer = setInterval(fetcher, frequency || 60000)
return {
subscribe,
@ -21,5 +26,3 @@ function makeRatePollStore () {
update
}
}
export const exchangeRates = makeRatePollStore()