253 lines
12 KiB
JavaScript
253 lines
12 KiB
JavaScript
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;
|