Skip to content

Commit

Permalink
Lightning eTips on profile page (#91)
Browse files Browse the repository at this point in the history
* unlock tokens before melting them (#89)

* move `/users/[slug]` to its own dir

* add `Notification` model to schema.prisma

* return boolean for `crossMintSwap` and `swapToClaim`

* add success handler to `ConfirmEcashReceiveModal`

* move `formatCents` to utils

* add new contact and token notifications

* fix build error

* fix - claim ALL proofs when `claimToken`

* dispatch token to tx history when claim via notification

* styling and render fixes

* change notif icon position

* style tweaks and render tips

* add tip notification type

* fix notifictions scroll and improve fetching

* create public `/api/tip` endpoints

* add LightningTipButton to profile page

* add checking timout

* add optional `amountUnit` to `createMintQuote`

* style changes +  show "add contact" when not added

* add `token` to mint quote model

* show SendEcashModal once etip is received

* modal refactor + style tweaks

* more style tweaks
  • Loading branch information
gudnuf authored Aug 12, 2024
1 parent 1241ebc commit 2606a00
Show file tree
Hide file tree
Showing 12 changed files with 490 additions and 49 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "MintQuote" ADD COLUMN "token" TEXT;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ model MintQuote {
pubkey String
mintKeyset MintKeyset @relation(fields: [mintKeysetId], references: [id])
mintKeysetId String
token String? // encoded token so that frontend can display it
}

model Mint {
Expand Down
238 changes: 238 additions & 0 deletions src/components/buttons/LightningTipButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { Button, Modal, Spinner, TextInput } from 'flowbite-react';
import QRCode from 'qrcode.react';
import ClipboardButton from '@/components/buttons/utility/ClipboardButton';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/util/useToast';
import { HttpResponseError, getInvoiceForTip, getTipStatus } from '@/utils/appApiRequests';
import { Validate, useForm } from 'react-hook-form';
import { useExchangeRate } from '@/hooks/util/useExchangeRate';
import { formatCents, formatSats } from '@/utils/formatting';
import SendEcashModalBody from '../modals/SendEcashModalBody';
import { PublicContact } from '@/types';

interface LightningTipButtonProps {
contact: PublicContact;
className?: string;
}

interface TipFormData {
amount: number;
}

type ModalPage = 'amount' | 'loading' | 'invoice';

const LightningTipButton = ({ contact, className }: LightningTipButtonProps) => {
const [showLightningTipModal, setShowLightningTipModal] = useState(false);
const [showTokenModal, setShowTokenModal] = useState(false);
const [currentPage, setCurrentPage] = useState<ModalPage>('amount');
const [invoiceTimeout, setInvoiceTimeout] = useState(false);
const [invoice, setInvoice] = useState('');
const [token, setToken] = useState('');
const [amountData, setAmountData] = useState<{
amountUsdCents: number;
amountSats: number;
} | null>(null);
const [quoteId, setQuoteId] = useState('');
const {
register,
handleSubmit,
formState: { errors },
reset: resetForm,
} = useForm<TipFormData>();
const { addToast } = useToast();
const { unitToSats } = useExchangeRate();

const handleModalClose = () => {
setInvoice('');
setShowLightningTipModal(false);
setInvoiceTimeout(false);
setCurrentPage('amount');
resetForm();
};

const onAmountSubmit = async (data: TipFormData) => {
setCurrentPage('loading');

const { amount } = data;

const amountUsdCents = parseFloat(Number(amount).toFixed(2)) * 100;
const amountSats = await unitToSats(amountUsdCents, 'usd');

setAmountData({ amountUsdCents, amountSats });

console.log('amountUsdCents', amountUsdCents);

await handleLightningTip(amountUsdCents);
};

const handleLightningTip = async (amountCents: number) => {
try {
const { checkingId, invoice } = await getInvoiceForTip(contact.pubkey, amountCents);

setInvoice(invoice);
setCurrentPage('invoice');

setQuoteId(checkingId);
await waitForPayment(checkingId);
} catch (error) {
console.error('Error fetching invoice for tip', error);
handleModalClose();
addToast('Error fetching invoice for tip', 'error');
}
};

const checkPaymentStatus = async (checkingId?: string): Promise<boolean> => {
if (!checkingId) {
checkingId = quoteId;
}
try {
const statusResponse = await getTipStatus(checkingId);
if (statusResponse.token) {
setToken(statusResponse.token);
}
return statusResponse.paid;
} catch (error) {
console.error('Error fetching tip status', error);
return false;
}
};

const handlePaymentSuccess = () => {
handleModalClose();
addToast('Sent!', 'success');
setShowTokenModal(true);
};

const waitForPayment = async (checkingId: string) => {
let attempts = 0;
const maxAttempts = 4;
const interval = setInterval(async () => {
const success = await checkPaymentStatus(checkingId);
if (success) {
clearInterval(interval);
handlePaymentSuccess();
}
if (attempts >= maxAttempts) {
clearInterval(interval);
setInvoiceTimeout(true);
return;
} else {
attempts++;
}
console.log('looking up payment for ', checkingId + '...');
}, 5000);
};

const handleCheckAgain = async () => {
setInvoiceTimeout(false);
const paid = await checkPaymentStatus();
if (paid) {
handlePaymentSuccess();
} else {
setInvoiceTimeout(true);
}
};

const validateAmount = (value: number): string | true => {
// const amount = parseFloat(value);
if (isNaN(value)) return 'Please enter a valid number';
if (value <= 0) return 'Amount must be positive';
if (value > 1000000) return 'Amount must not exceed 1,000,000';
if (value.toString().split('.')[1]?.length > 2) {
return 'Amount must not have more than 2 decimal places';
}
return true;
};

const renderModalContent = () => {
switch (currentPage) {
case 'amount':
return (
<form className='flex flex-col space-y-4' onSubmit={handleSubmit(onAmountSubmit)}>
<TextInput
type='text'
inputMode='decimal'
placeholder='Amount in USD (eg. 0.21)'
{...register('amount', {
required: 'Amount is required',
min: { value: 0, message: 'Amount must be positive' },
validate: validateAmount,
valueAsNumber: true,
})}
/>
{errors.amount && <span className='text-red-500'>{errors.amount.message}</span>}
<Button type='submit' className='btn-primary'>
Continue
</Button>
</form>
);
case 'loading':
return (
<div className='flex flex-col items-center justify-center space-y-3'>
<Spinner size='lg' />
{/* <p className='text-black'>Getting invoice...</p> */}
</div>
);
case 'invoice':
return (
<div className='flex flex-col items-center justify-center space-y-4'>
<p className='text-black'>Scan with any Lightning wallet</p>
{amountData && (
<div className='bg-white bg-opacity-90 p-2 rounded shadow-md'>
<div className='flex items-center justify-center space-x-5 text-black'>
<div>{formatCents(amountData.amountUsdCents)}</div>
<div>|</div>
<div>{formatSats(amountData.amountSats)}</div>
</div>
</div>
)}
<QRCode value={invoice} size={256} />
<ClipboardButton toCopy={invoice} toShow='Copy' className='btn-primary' />
<div className='text-black'>
{invoiceTimeout ? (
<div className='flex flex-col items-center justify-center text-center space-y-4'>
<p>Timed out waiting for payment...</p>
<button className='underline' onClick={handleCheckAgain}>
Check again
</button>
</div>
) : (
<div>
<Spinner /> Waiting for payment...
</div>
)}
</div>
</div>
);
}
};

const eTipHeader = useMemo(() => {
if (currentPage === 'loading') {
return 'Getting invoice...';
} else {
return `eTip for ${contact.username}`;
}
}, [contact.username, currentPage]);

return (
<>
<Button
className={`etip-button ${className}`}
onClick={() => setShowLightningTipModal(true)}
>
eTip
</Button>
<Modal show={showLightningTipModal} size='lg' onClose={handleModalClose}>
<Modal.Header>{eTipHeader}</Modal.Header>
<Modal.Body>{renderModalContent()}</Modal.Body>
</Modal>
<Modal show={showTokenModal} onClose={() => setShowTokenModal(false)}>
<Modal.Header>eTip for {contact.username}</Modal.Header>
<SendEcashModalBody token={token} onClose={() => setShowTokenModal(false)} />
</Modal>
</>
);
};

export default LightningTipButton;
17 changes: 15 additions & 2 deletions src/lib/mintQuoteModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const createMintQuote = async (
request: string,
pubkey: string,
keysetId: string,
amountUnit?: number,
) => {
const { amount, expiry } = getAmountAndExpiryFromInvoice(request);

Expand All @@ -14,7 +15,7 @@ export const createMintQuote = async (
id: quoteId,
request,
pubkey,
amount,
amount: amountUnit ? amountUnit : amount, // this is a hack to get the amount in the correct unit
expiryUnix: expiry,
paid: false,
mintKeysetId: keysetId,
Expand Down Expand Up @@ -44,7 +45,7 @@ export const findMintQuotesToRedeem = async () => {
return quotes;
};

export const updateMintQuote = async (quoteId: string, data: { paid: boolean }) => {
export const updateMintQuote = async (quoteId: string, data: { paid: boolean; token?: string }) => {
const quote = await prisma.mintQuote.update({
where: {
id: quoteId,
Expand All @@ -53,3 +54,15 @@ export const updateMintQuote = async (quoteId: string, data: { paid: boolean })
});
return quote;
};

export const getMintQuote = async (quoteId: string) => {
const quote = await prisma.mintQuote.findUnique({
where: {
id: quoteId,
},
include: {
mintKeyset: true,
},
});
return quote;
};
Loading

0 comments on commit 2606a00

Please sign in to comment.