8 changed files with 753 additions and 0 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<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