8 changed files with 753 additions and 0 deletions
@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
|
||||
.zap-button-component { |
||||
margin: var(--spacing-2) 0; |
||||
} |
||||
.zap-modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
z-index: 1050; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.zap-modal-content { |
||||
max-width: 450px; |
||||
width: 100%; |
||||
max-height: 90vh; |
||||
overflow-y: auto; |
||||
} |
||||
|
||||
.zap-modal-content .card-body { |
||||
padding: 1.5rem; |
||||
} |
||||
|
||||
.qr-container svg { |
||||
max-width: 100%; |
||||
height: auto; |
||||
border: 1px solid #dee2e6; |
||||
border-radius: 0.375rem; |
||||
padding: 0.5rem; |
||||
background: white; |
||||
} |
||||
|
||||
.zap-trigger:hover { |
||||
transform: scale(1.05); |
||||
transition: transform 0.2s ease; |
||||
} |
||||
|
||||
/* Prevent body scroll when modal is open */ |
||||
body:has(.zap-modal-overlay) { |
||||
overflow: hidden; |
||||
} |
||||
@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Contracts\HttpClient\HttpClientInterface; |
||||
use function tkijewski\lnurl\decodeUrl; |
||||
|
||||
/** |
||||
* Resolves Lightning Addresses (LUD-16) and LNURL-pay (LUD-06) endpoints |
||||
* to obtain LNURL-pay info for NIP-57 zaps |
||||
*/ |
||||
class LNURLResolver |
||||
{ |
||||
public function __construct( |
||||
private readonly HttpClientInterface $httpClient, |
||||
private readonly LoggerInterface $logger, |
||||
) {} |
||||
|
||||
/** |
||||
* Resolve a Lightning Address (name@domain) or lnurl to LNURL-pay info |
||||
* |
||||
* @param string|null $lud16 Lightning Address (e.g., "alice@example.com") |
||||
* @param string|null $lud06 LNURL bech32 string (e.g., "lnurl1...") |
||||
* @return object Object with callback, minSendable, maxSendable, allowsNostr, nostrPubkey, bech32 |
||||
* @throws \RuntimeException If resolution fails |
||||
*/ |
||||
public function resolve(?string $lud16 = null, ?string $lud06 = null): object |
||||
{ |
||||
if (!$lud16 && !$lud06) { |
||||
throw new \RuntimeException('No Lightning Address or LNURL provided'); |
||||
} |
||||
|
||||
try { |
||||
// Prefer LUD-16 (Lightning Address) over LUD-06 |
||||
if ($lud16) { |
||||
return $this->resolveLightningAddress($lud16); |
||||
} |
||||
|
||||
return $this->resolveLnurl($lud06); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('LNURL resolution failed', [ |
||||
'lud16' => $lud16, |
||||
'lud06' => $lud06, |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
throw new \RuntimeException('Could not resolve Lightning endpoint: ' . $e->getMessage()); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Resolve a Lightning Address (LUD-16) |
||||
*/ |
||||
private function resolveLightningAddress(string $address): object |
||||
{ |
||||
if (!preg_match('/^(.+)@(.+)$/', $address, $matches)) { |
||||
throw new \RuntimeException('Invalid Lightning Address format'); |
||||
} |
||||
|
||||
[$_, $name, $domain] = $matches; |
||||
$url = sprintf('https://%s/.well-known/lnurlp/%s', $domain, $name); |
||||
|
||||
try { |
||||
$response = $this->httpClient->request('GET', $url, [ |
||||
'timeout' => 10, |
||||
'headers' => ['Accept' => 'application/json'], |
||||
]); |
||||
|
||||
if ($response->getStatusCode() !== 200) { |
||||
throw new \RuntimeException('Lightning Address endpoint returned status ' . $response->getStatusCode()); |
||||
} |
||||
|
||||
$data = $response->toArray(); |
||||
return $this->parseLnurlPayResponse($data, null); |
||||
} catch (\Exception $e) { |
||||
throw new \RuntimeException('Failed to fetch Lightning Address info: ' . $e->getMessage()); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Resolve a bech32 LNURL (LUD-06) |
||||
*/ |
||||
private function resolveLnurl(string $lnurl): object |
||||
{ |
||||
try { |
||||
// Decode bech32 LNURL to get the actual URL |
||||
$decoded = decodeUrl($lnurl); |
||||
$url = $decoded['url'] ?? ''; |
||||
|
||||
if (empty($url)) { |
||||
throw new \RuntimeException('Could not decode LNURL'); |
||||
} |
||||
|
||||
$response = $this->httpClient->request('GET', $url, [ |
||||
'timeout' => 10, |
||||
'headers' => ['Accept' => 'application/json'], |
||||
]); |
||||
|
||||
if ($response->getStatusCode() !== 200) { |
||||
throw new \RuntimeException('LNURL endpoint returned status ' . $response->getStatusCode()); |
||||
} |
||||
|
||||
$data = $response->toArray(); |
||||
return $this->parseLnurlPayResponse($data, $lnurl); |
||||
} catch (\Exception $e) { |
||||
throw new \RuntimeException('Failed to decode or fetch LNURL: ' . $e->getMessage()); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse LNURL-pay response and validate required fields |
||||
*/ |
||||
private function parseLnurlPayResponse(array $data, ?string $bech32): object |
||||
{ |
||||
// Validate required LNURL-pay fields |
||||
if (!isset($data['callback']) || !isset($data['minSendable']) || !isset($data['maxSendable'])) { |
||||
throw new \RuntimeException('Invalid LNURL-pay response: missing required fields'); |
||||
} |
||||
|
||||
if (isset($data['tag']) && $data['tag'] !== 'payRequest') { |
||||
throw new \RuntimeException('Not a LNURL-pay endpoint'); |
||||
} |
||||
|
||||
// NIP-57 specific fields |
||||
$allowsNostr = isset($data['allowsNostr']) && $data['allowsNostr'] === true; |
||||
$nostrPubkey = $data['nostrPubkey'] ?? null; |
||||
|
||||
return (object) [ |
||||
'callback' => $data['callback'], |
||||
'minSendable' => (int) $data['minSendable'], |
||||
'maxSendable' => (int) $data['maxSendable'], |
||||
'allowsNostr' => $allowsNostr, |
||||
'nostrPubkey' => $nostrPubkey, |
||||
'metadata' => $data['metadata'] ?? '[]', |
||||
'bech32' => $bech32, |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* Request a BOLT11 invoice from the LNURL callback |
||||
* |
||||
* @param string $callback The callback URL from LNURL-pay info |
||||
* @param int $amountMillisats Amount in millisatoshis |
||||
* @param string|null $nostrEvent Signed NIP-57 zap request event (JSON) |
||||
* @param string|null $lnurl Original LNURL bech32 (if available) |
||||
* @return string BOLT11 invoice |
||||
* @throws \RuntimeException If invoice request fails |
||||
*/ |
||||
public function requestInvoice( |
||||
string $callback, |
||||
int $amountMillisats, |
||||
?string $nostrEvent = null, |
||||
?string $lnurl = null |
||||
): string { |
||||
try { |
||||
// Build query parameters |
||||
$params = ['amount' => $amountMillisats]; |
||||
|
||||
if ($nostrEvent !== null) { |
||||
$params['nostr'] = $nostrEvent; |
||||
} |
||||
|
||||
if ($lnurl !== null) { |
||||
$params['lnurl'] = $lnurl; |
||||
} |
||||
|
||||
// Some LNURL services expect the callback URL to already contain query params |
||||
$separator = str_contains($callback, '?') ? '&' : '?'; |
||||
$url = $callback . $separator . http_build_query($params); |
||||
|
||||
$response = $this->httpClient->request('GET', $url, [ |
||||
'timeout' => 15, |
||||
'headers' => ['Accept' => 'application/json'], |
||||
]); |
||||
|
||||
if ($response->getStatusCode() !== 200) { |
||||
throw new \RuntimeException('Callback returned status ' . $response->getStatusCode()); |
||||
} |
||||
|
||||
$data = $response->toArray(); |
||||
|
||||
// Check for error in response |
||||
if (isset($data['status']) && $data['status'] === 'ERROR') { |
||||
throw new \RuntimeException($data['reason'] ?? 'Unknown error from Lightning service'); |
||||
} |
||||
|
||||
if (!isset($data['pr'])) { |
||||
throw new \RuntimeException('No invoice (pr) in callback response'); |
||||
} |
||||
|
||||
return $data['pr']; |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('LNURL invoice request failed', [ |
||||
'callback' => $callback, |
||||
'amount' => $amountMillisats, |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
throw new \RuntimeException('Could not obtain invoice: ' . $e->getMessage()); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use swentel\nostr\Event\Event; |
||||
use swentel\nostr\Key\Key; |
||||
use swentel\nostr\Sign\Sign; |
||||
|
||||
/** |
||||
* Service for signing Nostr events |
||||
* For zap requests, we use ephemeral anonymous keys since these don't need user identity |
||||
*/ |
||||
class NostrSigner |
||||
{ |
||||
private Key $key; |
||||
|
||||
public function __construct() |
||||
{ |
||||
$this->key = new Key(); |
||||
} |
||||
|
||||
/** |
||||
* Sign a Nostr event with an ephemeral key |
||||
* Returns the signed event as JSON string |
||||
* |
||||
* @param int $kind Event kind |
||||
* @param array $tags Event tags |
||||
* @param string $content Event content |
||||
* @param int|null $createdAt Optional timestamp (defaults to now) |
||||
* @return string JSON-encoded signed event |
||||
*/ |
||||
public function signEphemeral(int $kind, array $tags, string $content = '', ?int $createdAt = null): string |
||||
{ |
||||
// Generate ephemeral key pair for anonymous zaps |
||||
$privateKey = bin2hex(random_bytes(32)); |
||||
$publicKey = $this->key->getPublicKey($privateKey); |
||||
|
||||
$event = new Event(); |
||||
$event->setKind($kind); |
||||
$event->setTags($tags); |
||||
$event->setContent($content); |
||||
$event->setCreatedAt($createdAt ?? time()); |
||||
|
||||
// Sign the event using the Sign class |
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, $privateKey); |
||||
|
||||
// Return as JSON using Event's built-in method |
||||
return $event->toJson(); |
||||
} |
||||
|
||||
/** |
||||
* Build and sign a NIP-57 zap request event (kind 9734) |
||||
* |
||||
* @param string $recipientPubkey Recipient's pubkey (hex) |
||||
* @param int $amountMillisats Amount in millisatoshis |
||||
* @param string $lnurl The LNURL or callback URL |
||||
* @param string $comment Optional comment/note |
||||
* @param array $relays Optional list of relays |
||||
* @return string JSON-encoded signed zap request |
||||
*/ |
||||
public function buildZapRequest( |
||||
string $recipientPubkey, |
||||
int $amountMillisats, |
||||
string $lnurl, |
||||
string $comment = '', |
||||
array $relays = [] |
||||
): string { |
||||
$tags = [ |
||||
['p', $recipientPubkey], |
||||
['amount', (string) $amountMillisats], |
||||
['lnurl', $lnurl], |
||||
]; |
||||
|
||||
// Add relays if provided |
||||
foreach ($relays as $relay) { |
||||
$tags[] = ['relays', $relay]; |
||||
} |
||||
|
||||
return $this->signEphemeral(9734, $tags, $comment); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use Endroid\QrCode\Builder\Builder; |
||||
use Endroid\QrCode\Encoding\Encoding; |
||||
use Endroid\QrCode\ErrorCorrectionLevel; |
||||
use Endroid\QrCode\RoundBlockSizeMode; |
||||
use Endroid\QrCode\Writer\SvgWriter; |
||||
|
||||
/** |
||||
* Simple QR code generator service using endroid/qr-code |
||||
*/ |
||||
class QRGenerator |
||||
{ |
||||
/** |
||||
* Generate an SVG QR code |
||||
* |
||||
* @param string $data The data to encode (e.g., BOLT11 invoice, Lightning URI) |
||||
* @param int $size QR code size in pixels |
||||
* @return string SVG markup |
||||
*/ |
||||
public function svg(string $data, int $size = 300): string |
||||
{ |
||||
$result = (new Builder())->build( |
||||
data: $data, |
||||
writer: new SvgWriter(), |
||||
encoding: new Encoding('UTF-8'), |
||||
errorCorrectionLevel: ErrorCorrectionLevel::Low, |
||||
size: $size, |
||||
margin: 10, |
||||
roundBlockSizeMode: RoundBlockSizeMode::Margin |
||||
); |
||||
|
||||
return $result->getString(); |
||||
} |
||||
|
||||
/** |
||||
* Generate a data URI (for embedding in img src) |
||||
* |
||||
* @param string $data The data to encode |
||||
* @param int $size QR code size in pixels |
||||
* @return string Data URI |
||||
*/ |
||||
public function dataUri(string $data, int $size = 300): string |
||||
{ |
||||
$result = (new Builder())->build( |
||||
data: $data, |
||||
encoding: new Encoding('UTF-8'), |
||||
errorCorrectionLevel: ErrorCorrectionLevel::Low, |
||||
size: $size, |
||||
margin: 10, |
||||
roundBlockSizeMode: RoundBlockSizeMode::Margin |
||||
); |
||||
|
||||
return $result->getDataUri(); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,196 @@
@@ -0,0 +1,196 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Twig\Components\Molecules; |
||||
|
||||
use App\Service\LNURLResolver; |
||||
use App\Service\NostrSigner; |
||||
use App\Service\QRGenerator; |
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||
use Symfony\UX\LiveComponent\Attribute\LiveAction; |
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp; |
||||
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||
|
||||
/** |
||||
* Minimalist NIP-57 Zap Button Component |
||||
* |
||||
* Provides a simple UI to zap Nostr users via their Lightning Address |
||||
* without requiring any third-party relay or LNbits backend. |
||||
*/ |
||||
#[AsLiveComponent] |
||||
final class ZapButton |
||||
{ |
||||
use DefaultActionTrait; |
||||
|
||||
// Props: Recipient info (passed from parent) |
||||
#[LiveProp] |
||||
public string $recipientPubkey = ''; |
||||
|
||||
#[LiveProp] |
||||
public ?string $recipientLud16 = null; |
||||
|
||||
#[LiveProp] |
||||
public ?string $recipientLud06 = null; |
||||
|
||||
// UI state props (internal) |
||||
#[LiveProp(writable: true)] |
||||
public bool $open = false; |
||||
|
||||
#[LiveProp(writable: true)] |
||||
public string $phase = 'idle'; // idle, input, loading, invoice, error |
||||
|
||||
#[LiveProp(writable: true)] |
||||
public int $amount = 21; // Amount in sats (default 21) |
||||
|
||||
#[LiveProp(writable: true)] |
||||
public string $comment = ''; |
||||
|
||||
#[LiveProp] |
||||
public string $error = ''; |
||||
|
||||
#[LiveProp] |
||||
public string $bolt11 = ''; |
||||
|
||||
#[LiveProp] |
||||
public string $qrSvg = ''; |
||||
|
||||
// LNURL info (stored after resolution) |
||||
#[LiveProp] |
||||
public int $minSendable = 1000; // millisats |
||||
|
||||
#[LiveProp] |
||||
public int $maxSendable = 11000000000; // millisats (11M sats default) |
||||
|
||||
public function __construct( |
||||
private readonly LNURLResolver $lnurlResolver, |
||||
private readonly NostrSigner $nostrSigner, |
||||
private readonly QRGenerator $qrGenerator, |
||||
private readonly LoggerInterface $logger, |
||||
) {} |
||||
|
||||
/** |
||||
* Open the zap dialog |
||||
*/ |
||||
#[LiveAction] |
||||
public function openDialog(): void |
||||
{ |
||||
if (!$this->recipientLud16 && !$this->recipientLud06) { |
||||
$this->error = 'Recipient has no Lightning Address configured'; |
||||
$this->phase = 'error'; |
||||
$this->open = true; |
||||
return; |
||||
} |
||||
|
||||
$this->open = true; |
||||
$this->phase = 'input'; |
||||
$this->error = ''; |
||||
$this->bolt11 = ''; |
||||
$this->qrSvg = ''; |
||||
} |
||||
|
||||
/** |
||||
* Close the dialog and reset state |
||||
*/ |
||||
#[LiveAction] |
||||
public function closeDialog(): void |
||||
{ |
||||
$this->open = false; |
||||
$this->phase = 'idle'; |
||||
$this->error = ''; |
||||
$this->bolt11 = ''; |
||||
$this->qrSvg = ''; |
||||
$this->comment = ''; |
||||
$this->amount = 21; |
||||
} |
||||
|
||||
/** |
||||
* Create a zap invoice |
||||
*/ |
||||
#[LiveAction] |
||||
public function createInvoice(): void |
||||
{ |
||||
$this->error = ''; |
||||
$this->phase = 'loading'; |
||||
|
||||
try { |
||||
// Validate amount |
||||
if ($this->amount <= 0) { |
||||
throw new \RuntimeException('Amount must be greater than 0'); |
||||
} |
||||
|
||||
// Resolve LNURL |
||||
$lnurlInfo = $this->lnurlResolver->resolve($this->recipientLud16, $this->recipientLud06); |
||||
|
||||
// Store min/max for validation |
||||
$this->minSendable = $lnurlInfo->minSendable; |
||||
$this->maxSendable = $lnurlInfo->maxSendable; |
||||
|
||||
// Validate NIP-57 support |
||||
if (!$lnurlInfo->allowsNostr) { |
||||
throw new \RuntimeException('Recipient does not support Nostr zaps (allowsNostr not enabled)'); |
||||
} |
||||
|
||||
if (!$lnurlInfo->nostrPubkey) { |
||||
throw new \RuntimeException('Recipient has not configured a Nostr pubkey for zaps'); |
||||
} |
||||
|
||||
// Convert sats to millisats |
||||
$amountMillisats = $this->amount * 1000; |
||||
|
||||
// Validate amount against limits |
||||
if ($amountMillisats < $lnurlInfo->minSendable) { |
||||
$minSats = (int) ceil($lnurlInfo->minSendable / 1000); |
||||
throw new \RuntimeException("Amount too low. Minimum: {$minSats} sats"); |
||||
} |
||||
|
||||
if ($amountMillisats > $lnurlInfo->maxSendable) { |
||||
$maxSats = (int) floor($lnurlInfo->maxSendable / 1000); |
||||
throw new \RuntimeException("Amount too high. Maximum: {$maxSats} sats"); |
||||
} |
||||
|
||||
// Build and sign NIP-57 zap request (kind 9734) |
||||
$zapRequestJson = $this->nostrSigner->buildZapRequest( |
||||
recipientPubkey: $this->recipientPubkey, |
||||
amountMillisats: $amountMillisats, |
||||
lnurl: $lnurlInfo->bech32 ?? ($this->recipientLud16 ?? $this->recipientLud06 ?? ''), |
||||
comment: $this->comment, |
||||
relays: [] // Optional: could add user's preferred relays |
||||
); |
||||
|
||||
// URL-encode the zap request for the callback |
||||
$nostrParam = urlencode($zapRequestJson); |
||||
|
||||
// Request invoice from LNURL callback |
||||
$this->bolt11 = $this->lnurlResolver->requestInvoice( |
||||
callback: $lnurlInfo->callback, |
||||
amountMillisats: $amountMillisats, |
||||
nostrEvent: $zapRequestJson, |
||||
lnurl: $lnurlInfo->bech32 |
||||
); |
||||
|
||||
// Generate QR code |
||||
$this->qrSvg = $this->qrGenerator->svg('lightning:' . strtoupper($this->bolt11), 280); |
||||
|
||||
$this->phase = 'invoice'; |
||||
|
||||
} catch (\RuntimeException $e) { |
||||
$this->error = $e->getMessage(); |
||||
$this->phase = 'error'; |
||||
$this->logger->error('Zap invoice creation failed', [ |
||||
'recipient' => $this->recipientPubkey, |
||||
'error' => $e->getMessage(), |
||||
]); |
||||
} catch (\Exception $e) { |
||||
$this->error = 'Could not reach Lightning endpoint. Please try again.'; |
||||
$this->phase = 'error'; |
||||
$this->logger->error('Zap invoice creation failed with unexpected error', [ |
||||
'recipient' => $this->recipientPubkey, |
||||
'error' => $e->getMessage(), |
||||
'trace' => $e->getTraceAsString(), |
||||
]); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
|
||||
<div {{ attributes.defaults({class: 'zap-button-component'}) }}> |
||||
{# Zap Button - always visible #} |
||||
<button |
||||
type="button" |
||||
class="btn btn-sm btn-outline-warning zap-trigger" |
||||
data-action="live#action" |
||||
data-live-action-param="openDialog" |
||||
title="Send a zap" |
||||
> |
||||
⚡ Zap |
||||
</button> |
||||
|
||||
{# Modal Dialog #} |
||||
{% if this.open %} |
||||
<div class="zap-modal-overlay" data-action="click->live#action" data-live-action-param="closeDialog"> |
||||
<div class="zap-modal-content card shadow-lg" onclick="event.stopPropagation()"> |
||||
<div class="card-body"> |
||||
{# Header #} |
||||
<div class="d-flex flex-row justify-content-between align-items-center mb-3"> |
||||
<h5 class="m-0">⚡ Send Zap</h5> |
||||
<button |
||||
type="button" |
||||
class="btn btn-secondary" |
||||
data-action="live#action" |
||||
data-live-action-param="closeDialog" |
||||
>Close</button> |
||||
</div> |
||||
|
||||
{# Phase: Input #} |
||||
{% if this.phase == 'input' %} |
||||
<form data-action="submit->live#action:prevent" data-live-action-param="createInvoice"> |
||||
<div class="mb-3"> |
||||
<label for="zap-amount" class="form-label">Amount (sats)</label> |
||||
<input |
||||
type="number" |
||||
id="zap-amount" |
||||
class="form-control" |
||||
min="1" |
||||
step="1" |
||||
data-model="debounce(300)|amount" |
||||
value="{{ amount }}" |
||||
required |
||||
autofocus |
||||
/> |
||||
<small class="form-text text-muted">Suggested: 21, 210, 2100 sats</small> |
||||
</div> |
||||
|
||||
<div class="mb-3"> |
||||
<label for="zap-comment" class="form-label">Note (optional)</label> |
||||
<textarea |
||||
id="zap-comment" |
||||
class="form-control" |
||||
rows="2" |
||||
data-model="norender|comment" |
||||
placeholder="Add a message..." |
||||
>{{ comment }}</textarea> |
||||
</div> |
||||
|
||||
<button type="submit" class="btn btn-warning w-100"> |
||||
⚡ Create Invoice |
||||
</button> |
||||
</form> |
||||
{% endif %} |
||||
|
||||
{# Phase: Loading #} |
||||
{% if this.phase == 'loading' %} |
||||
<div class="text-center py-4"> |
||||
<div class="spinner-border text-warning mb-3" role="status"> |
||||
<span class="visually-hidden">Loading...</span> |
||||
</div> |
||||
<p class="text-muted">Creating invoice...</p> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{# Phase: Invoice #} |
||||
{% if this.phase == 'invoice' %} |
||||
<div class="zap-invoice"> |
||||
<div class="text-center mb-3"> |
||||
<p class="text-success mb-2">✓ Invoice ready!</p> |
||||
<p class="text-muted small">Scan with your Lightning wallet</p> |
||||
</div> |
||||
|
||||
{# QR Code #} |
||||
<div class="qr-container text-center mb-3"> |
||||
{{ qrSvg|raw }} |
||||
</div> |
||||
|
||||
{# BOLT11 Invoice String #} |
||||
<div class="mb-3"> |
||||
<label class="form-label small text-muted">BOLT11 Invoice:</label> |
||||
<div class="input-group input-group-sm"> |
||||
<input |
||||
type="text" |
||||
class="form-control font-monospace small" |
||||
value="{{ bolt11 }}" |
||||
readonly |
||||
onclick="this.select()" |
||||
/> |
||||
<button |
||||
class="btn btn-outline-secondary" |
||||
type="button" |
||||
onclick="navigator.clipboard.writeText('{{ bolt11|e('js') }}'); this.textContent='Copied!'; setTimeout(() => this.textContent='Copy', 2000)" |
||||
> |
||||
Copy |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
{# Lightning URI Link #} |
||||
<div class="text-center mb-3"> |
||||
<a |
||||
href="lightning:{{ bolt11|upper }}" |
||||
class="btn btn-warning w-100" |
||||
> |
||||
⚡ Open in Wallet |
||||
</a> |
||||
<small class="form-text text-muted d-block mt-2"> |
||||
Or scan the QR code above |
||||
</small> |
||||
</div> |
||||
|
||||
{# Back button #} |
||||
<button |
||||
type="button" |
||||
class="btn btn-sm btn-outline-secondary w-100" |
||||
data-action="live#action" |
||||
data-live-action-param="openDialog" |
||||
> |
||||
← Create Another Zap |
||||
</button> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{# Phase: Error #} |
||||
{% if this.phase == 'error' %} |
||||
<div class="alert alert-danger mb-3"> |
||||
<strong>Error:</strong> {{ error }} |
||||
</div> |
||||
<button |
||||
type="button" |
||||
class="btn btn-outline-primary w-100" |
||||
data-action="live#action" |
||||
data-live-action-param="openDialog" |
||||
> |
||||
← Try Again |
||||
</button> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
|
||||
Loading…
Reference in new issue