Merge branch 'dev' into theme-provider
This commit is contained in:
252
src/components/TransactionHistory.js
Normal file
252
src/components/TransactionHistory.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Receipt, CreditCard, Heart, Calendar, ExternalLink, DollarSign } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* TransactionHistory Component
|
||||
* Displays user transaction history including subscriptions and donations
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.subscriptions - List of subscription transactions
|
||||
* @param {Array} props.donations - List of donation transactions
|
||||
* @param {number} props.totalSubscriptionCents - Total subscription amount in cents
|
||||
* @param {number} props.totalDonationCents - Total donation amount in cents
|
||||
* @param {boolean} props.loading - Loading state
|
||||
* @param {boolean} props.isAdmin - Whether viewing as admin (shows extra fields)
|
||||
*/
|
||||
const TransactionHistory = ({
|
||||
subscriptions = [],
|
||||
donations = [],
|
||||
totalSubscriptionCents = 0,
|
||||
totalDonationCents = 0,
|
||||
loading = false,
|
||||
isAdmin = false
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
const formatAmount = (cents) => {
|
||||
if (!cents) return '$0.00';
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadgeClass = (status) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'active':
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'cancelled':
|
||||
case 'failed':
|
||||
case 'expired':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const allTransactions = [
|
||||
...subscriptions.map(s => ({ ...s, sortDate: s.created_at })),
|
||||
...donations.map(d => ({ ...d, sortDate: d.created_at }))
|
||||
].sort((a, b) => new Date(b.sortDate) - new Date(a.sortDate));
|
||||
|
||||
const TransactionRow = ({ transaction }) => {
|
||||
const isSubscription = transaction.type === 'subscription';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-[var(--neutral-800)] last:border-b-0 hover:bg-[var(--lavender-500)] transition-colors">
|
||||
<div className="flex items-start gap-3 mb-2 sm:mb-0">
|
||||
<div className={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
|
||||
{isSubscription ? (
|
||||
<CreditCard className="h-5 w-5 text-[var(--purple-lavender)]" />
|
||||
) : (
|
||||
<Heart className="h-5 w-5 text-[var(--orange-light)]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{transaction.description}
|
||||
</span>
|
||||
<Badge className={`text-xs ${getStatusBadgeClass(transaction.status)}`}>
|
||||
{transaction.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-brand-purple mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(transaction.payment_completed_at || transaction.created_at)}</span>
|
||||
{transaction.card_brand && transaction.card_last4 && (
|
||||
<>
|
||||
<span className="text-[var(--neutral-800)]">•</span>
|
||||
<span>{transaction.card_brand} ****{transaction.card_last4}</span>
|
||||
</>
|
||||
)}
|
||||
{isSubscription && transaction.billing_cycle && (
|
||||
<>
|
||||
<span className="text-[var(--neutral-800)]">•</span>
|
||||
<span className="capitalize">{transaction.billing_cycle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && transaction.manual_payment && (
|
||||
<div className="text-xs text-[var(--orange-light)] mt-1">
|
||||
Manual Payment {transaction.manual_payment_notes && `- ${transaction.manual_payment_notes}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-10 sm:pl-0">
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(transaction.amount_cents)}
|
||||
</div>
|
||||
{isSubscription && transaction.donation_cents > 0 && (
|
||||
<div className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
(incl. {formatAmount(transaction.donation_cents)} donation)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{transaction.stripe_receipt_url && (
|
||||
<a
|
||||
href={transaction.stripe_receipt_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 text-brand-purple hover:text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg transition-colors"
|
||||
title="View Receipt"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = ({ type }) => (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-[var(--lavender-300)] rounded-full flex items-center justify-center mb-4">
|
||||
{type === 'subscription' ? (
|
||||
<CreditCard className="h-8 w-8 text-brand-purple" />
|
||||
) : type === 'donation' ? (
|
||||
<Heart className="h-8 w-8 text-brand-purple" />
|
||||
) : (
|
||||
<Receipt className="h-8 w-8 text-brand-purple" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{type === 'subscription'
|
||||
? 'No subscription payments yet'
|
||||
: type === 'donation'
|
||||
? 'No donations yet'
|
||||
: 'No transactions yet'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--purple-lavender)]"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Receipt className="h-6 w-6 text-brand-purple" />
|
||||
Transaction History
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
<span className="text-sm">Total Subscriptions</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(totalSubscriptionCents)}
|
||||
</div>
|
||||
<div className="text-xs text-brand-purple mt-1">{subscriptions.length} payment(s)</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Heart className="h-4 w-4" />
|
||||
<span className="text-sm">Total Donations</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(totalDonationCents)}
|
||||
</div>
|
||||
<div className="text-xs text-brand-purple mt-1">{donations.length} donation(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-4">
|
||||
<TabsTrigger value="all" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
All ({allTransactions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subscriptions" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
Subscriptions ({subscriptions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="donations" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
Donations ({donations.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="border border-[var(--neutral-800)] rounded-xl overflow-hidden">
|
||||
<TabsContent value="all" className="m-0">
|
||||
{allTransactions.length > 0 ? (
|
||||
allTransactions.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="all" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscriptions" className="m-0">
|
||||
{subscriptions.length > 0 ? (
|
||||
subscriptions.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="subscription" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="donations" className="m-0">
|
||||
{donations.length > 0 ? (
|
||||
donations.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="donation" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionHistory;
|
||||
Reference in New Issue
Block a user