diff --git a/.changeset/bill-1034-download-receipt.md b/.changeset/bill-1034-download-receipt.md new file mode 100644 index 00000000000..05fd353c817 --- /dev/null +++ b/.changeset/bill-1034-download-receipt.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add a download action to the payment receipt (payment attempt) page in the `` and `` billing sections. Payers can now save a receipt as a PDF through the browser's print dialog instead of digging through their email. The downloaded receipt mirrors the emailed billing receipt (plan, seats, proration, credits, totals, payment details). Adds a new `paymentAttemptDownloadButton` appearance element for customization. diff --git a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx index 55d57251b3d..d17081d5c43 100644 --- a/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx +++ b/packages/ui/src/components/PaymentAttempts/PaymentAttemptPage.tsx @@ -1,15 +1,20 @@ -import { __internal_usePaymentAttemptQuery } from '@clerk/shared/react/index'; +import { getIdentifier } from '@clerk/shared/internal/clerk-js/user'; +import { __internal_usePaymentAttemptQuery, useOrganization, useUser } from '@clerk/shared/react'; import type { BillingPaymentResource } from '@clerk/shared/types'; +import { useEffect, useState } from 'react'; import { Alert } from '@/ui/elements/Alert'; import { Header } from '@/ui/elements/Header'; import { LineItems } from '@/ui/elements/LineItems'; import { ProfileCard } from '@/ui/elements/ProfileCard'; +import { Tooltip } from '@/ui/elements/Tooltip'; import { toNegativeAmount } from '@/ui/utils/billing'; import { getPlanSeatLimit, getSeatsPerUnitTotal, summarizeSeatCharges } from '@/ui/utils/billingPlanSeats'; import { formatDate } from '@/ui/utils/formatDate'; import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible'; +import { PrintableComponent, usePrintable } from '../../common'; +import { useEnvironment } from '../../contexts'; import { useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts/components'; import { Badge, @@ -25,14 +30,15 @@ import { useLocalizations, } from '../../customizables'; import { useClipboard } from '../../hooks'; -import { Checkmark, Copy } from '../../icons'; +import { ArrowDownTray, Checkmark, Copy } from '../../icons'; import { useRouter } from '../../router'; export const PaymentAttemptPage = () => { const { params, navigate } = useRouter(); const subscriberType = useSubscriberTypeContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); - const { t, translateError, $ } = useLocalizations(); + const { t, translateError } = useLocalizations(); + const { print, printableProps } = usePrintable(); const requesterType = subscriberType === 'organization' ? 'organization' : 'user'; const { @@ -89,116 +95,173 @@ export const PaymentAttemptPage = () => { ) : ( - ({ - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$borderAlpha100, - borderRadius: t.radii.$lg, - overflow: 'clip', - })} - > - ({ - padding: t.space.$4, - background: t.colors.$neutralAlpha25, - display: 'flex', - justifyContent: 'space-between', - alignItems: 'flex-start', - })} - > - - - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - color: t.colors.$colorMutedForeground, - })} - > - - - {truncateWithEndVisible(paymentAttempt.id)} - - - - - {paymentAttempt.status} - - - - + + {/* Off-screen copy rendered into an iframe for the browser print / "Save as PDF" dialog. + Mirrors the emailed billing receipt rather than the in-app card. */} + + + + + )} + + ); +}; + +function PaymentAttemptCard({ + paymentAttempt, + onDownload, +}: { + paymentAttempt: BillingPaymentResource; + onDownload?: () => void; +}) { + const { $ } = useLocalizations(); + + return ( + ({ + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$borderAlpha100, + borderRadius: t.radii.$lg, + overflow: 'clip', + })} + > + ({ + padding: t.space.$4, + background: t.colors.$neutralAlpha25, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + })} + > + + + ({ - paddingInline: t.space.$4, - paddingBlock: t.space.$3, - background: t.colors.$neutralAlpha25, - borderBlockStartWidth: t.borderWidths.$normal, - borderBlockStartStyle: t.borderStyles.$solid, - borderBlockStartColor: t.colors.$borderAlpha100, display: 'flex', - justifyContent: 'space-between', + alignItems: 'center', + gap: t.space.$0x25, + color: t.colors.$colorMutedForeground, })} > - - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$2x5, - })} + - - {paymentAttempt.amount.currency} - - - {$(paymentAttempt.amount)} - - - - - )} - + {truncateWithEndVisible(paymentAttempt.id)} + + + + ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$2, + })} + > + {onDownload && ( + + + + + + + )} + + {paymentAttempt.status} + + + + + ({ + paddingInline: t.space.$4, + paddingBlock: t.space.$3, + background: t.colors.$neutralAlpha25, + borderBlockStartWidth: t.borderWidths.$normal, + borderBlockStartStyle: t.borderStyles.$solid, + borderBlockStartColor: t.colors.$borderAlpha100, + display: 'flex', + justifyContent: 'space-between', + })} + > + + ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$2x5, + })} + > + + {paymentAttempt.amount.currency} + + + {$(paymentAttempt.amount)} + + + + ); -}; +} function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPaymentResource | undefined }) { const { $ } = useLocalizations(); @@ -306,6 +369,250 @@ function PaymentAttemptBody({ paymentAttempt }: { paymentAttempt: BillingPayment ); } +const RECEIPT_MUTED = '#757575'; + +// NOTE: The receipt below is styled with inline `style` attributes rather than the `sx`/emotion +// system on purpose. `PrintableComponent` clones this markup into a print iframe and copies emotion +// styles by reading the `