Files
membership-fe/src/components/TransactionHistory.js
Koncept Kit 002ef5c897 Fixes
2026-01-24 23:56:15 +07:00

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;