Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,406 changes: 2,198 additions & 3,208 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/app/components/video/AdvancedVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { TranscriptView } from './TranscriptView';
import { clamp, formatTime } from '@/utils/videoUtils';
import { usePlaybackAnalytics } from './PlaybackAnalytics';
import { VideoPlayerContext } from './VideoPlayerContext';
import { AudioInvoiceManager, AudioInvoiceButton } from '@/components/audio';

export type VideoQualityOption = {
label: string;
Expand Down Expand Up @@ -58,6 +59,7 @@ export function AdvancedVideoPlayer(props: AdvancedVideoPlayerProps) {
const [showTranscript, setShowTranscript] = useState(false);
const [showNotes, setShowNotes] = useState(false);
const [showBookmarks, setShowBookmarks] = useState(false);
const [showInvoices, setShowInvoices] = useState(false);

const [announcement, setAnnouncement] = useState('');
const [touchStartX, setTouchStartX] = useState(0);
Expand Down Expand Up @@ -567,6 +569,8 @@ export function AdvancedVideoPlayer(props: AdvancedVideoPlayerProps) {
</button>
)}

<AudioInvoiceButton onClick={() => setShowInvoices(true)} />

<button
onClick={toggleFullscreen}
className="p-3 rounded bg-white/20 hover:bg-white/30 transition-colors md:p-2"
Expand All @@ -593,6 +597,12 @@ export function AdvancedVideoPlayer(props: AdvancedVideoPlayerProps) {
)}
</AnimatePresence>

<AudioInvoiceManager
isOpen={showInvoices}
onClose={() => setShowInvoices(false)}
lessonId={lessonId}
/>

{/* Side Panels */}
<div className="absolute top-0 right-0 h-full flex">
<AnimatePresence>
Expand Down
12 changes: 12 additions & 0 deletions src/app/components/video/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { VideoPlayerContext } from './VideoPlayerContext';
import type { VideoPlayerContextValue } from './VideoPlayerContext';
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
import { useVideoLazyLoad } from '../../hooks/useVideoLazyLoad';
import { AudioInvoiceManager, AudioInvoiceButton } from '@/components/audio';

interface VideoPlayerProps {
src: string;
Expand All @@ -30,6 +31,7 @@ interface VideoPlayerProps {
onBookmark?: (bookmark: { time: number; title: string; note?: string }) => void;
onNote?: (note: { time: number; text: string }) => void;
className?: string;
lessonId?: string;
}

export const VideoPlayer: React.FC<VideoPlayerProps> = ({
Expand All @@ -40,6 +42,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
onBookmark,
onNote,
className = '',
lessonId,
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
Expand All @@ -49,6 +52,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
const [showTranscript, setShowTranscript] = useState(false);
const [showNotes, setShowNotes] = useState(false);
const [showBookmarks, setShowBookmarks] = useState(false);
const [showInvoices, setShowInvoices] = useState(false);
const [announcement, setAnnouncement] = useState('');
const [touchStartX, setTouchStartX] = useState(0);
const [touchStartTime, setTouchStartTime] = useState(0);
Expand Down Expand Up @@ -572,6 +576,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
</button>
)}

<AudioInvoiceButton onClick={() => setShowInvoices(true)} />

<button
onClick={toggleFullscreen}
className="p-3 rounded bg-white/20 hover:bg-white/30 transition-colors md:p-2"
Expand All @@ -593,6 +599,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
)}
</AnimatePresence>

<AudioInvoiceManager
isOpen={showInvoices}
onClose={() => setShowInvoices(false)}
lessonId={lessonId}
/>

{/* Side Panels */}
<div className="absolute top-0 right-0 h-full flex">
<AnimatePresence>
Expand Down
52 changes: 52 additions & 0 deletions src/components/audio/AudioInvoiceBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { CheckCircle, Clock, XCircle, AlertTriangle, Ban } from 'lucide-react';
import type { InvoiceStatus } from '@/types/invoice';

const statusConfig: Record<
InvoiceStatus,
{ label: string; icon: React.ReactNode; className: string }
> = {
paid: {
label: 'Paid',
icon: <CheckCircle size={14} />,
className: 'text-green-700 bg-green-100',
},
pending: {
label: 'Pending',
icon: <Clock size={14} />,
className: 'text-yellow-700 bg-yellow-100',
},
failed: {
label: 'Failed',
icon: <XCircle size={14} />,
className: 'text-red-700 bg-red-100',
},
refunded: {
label: 'Refunded',
icon: <AlertTriangle size={14} />,
className: 'text-orange-700 bg-orange-100',
},
cancelled: {
label: 'Cancelled',
icon: <Ban size={14} />,
className: 'text-gray-600 bg-gray-200',
},
};

interface AudioInvoiceBadgeProps {
status: InvoiceStatus;
}

export function AudioInvoiceBadge({ status }: AudioInvoiceBadgeProps) {
const config = statusConfig[status];

return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}
role="status"
aria-label={`Invoice status: ${config.label}`}
>
{config.icon}
{config.label}
</span>
);
}
68 changes: 68 additions & 0 deletions src/components/audio/AudioInvoiceDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { ArrowLeft, Calendar, DollarSign, FileText, Mail, User } from 'lucide-react';
import { AudioInvoiceBadge } from './AudioInvoiceBadge';
import type { Invoice } from '@/types/invoice';

interface AudioInvoiceDetailProps {
invoice: Invoice;
onBack: () => void;
}

export function AudioInvoiceDetail({ invoice, onBack }: AudioInvoiceDetailProps) {
return (
<div role="region" aria-label={`Invoice detail for ${invoice.title}`}>
<button
onClick={onBack}
className="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 mb-4"
aria-label="Back to invoice list"
>
<ArrowLeft size={16} />
Back to invoices
</button>

<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">{invoice.title}</h3>
<p className="text-sm text-gray-500 mt-1">Invoice #{invoice.id}</p>
</div>
<AudioInvoiceBadge status={invoice.status} />
</div>

{invoice.description && (
<div className="flex items-start gap-2 text-sm text-gray-600">
<FileText size={16} className="mt-0.5 flex-shrink-0" />
<span>{invoice.description}</span>
</div>
)}

<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-gray-600">
<User size={16} className="flex-shrink-0" />
<span>{invoice.buyerName}</span>
</div>
<div className="flex items-center gap-2 text-gray-600">
<Mail size={16} className="flex-shrink-0" />
<span>{invoice.buyerEmail}</span>
</div>
<div className="flex items-center gap-2 text-gray-600">
<DollarSign size={16} className="flex-shrink-0" />
<span>
{invoice.amount.toFixed(2)} {invoice.currency}
</span>
</div>
<div className="flex items-center gap-2 text-gray-600">
<Calendar size={16} className="flex-shrink-0" />
<span>Issued: {new Date(invoice.issuedAt).toLocaleDateString()}</span>
</div>
</div>

{invoice.paidAt && (
<div className="flex items-center gap-2 text-sm text-green-600">
<Calendar size={16} />
<span>Paid on: {new Date(invoice.paidAt).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
);
}
171 changes: 171 additions & 0 deletions src/components/audio/AudioInvoiceList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { Search, X } from 'lucide-react';
import { AudioInvoiceBadge } from './AudioInvoiceBadge';
import type {
Invoice,
InvoiceFilter,
InvoiceSummary,
InvoiceStatus,
InvoiceContentType,
} from '@/types/invoice';

interface AudioInvoiceListProps {
invoices: Invoice[];
summary: InvoiceSummary;
filter: InvoiceFilter;
onFilterChange: (filter: InvoiceFilter) => void;
onSelectInvoice: (id: string) => void;
onClearFilter: () => void;
}

const statusOptions: { value: InvoiceStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'All Statuses' },
{ value: 'paid', label: 'Paid' },
{ value: 'pending', label: 'Pending' },
{ value: 'failed', label: 'Failed' },
{ value: 'refunded', label: 'Refunded' },
{ value: 'cancelled', label: 'Cancelled' },
];

const contentTypeOptions: { value: InvoiceContentType | 'all'; label: string }[] = [
{ value: 'all', label: 'All Types' },
{ value: 'lesson', label: 'Lesson' },
{ value: 'course', label: 'Course' },
{ value: 'audio', label: 'Audio' },
{ value: 'video', label: 'Video' },
{ value: 'material', label: 'Material' },
];

export function AudioInvoiceList({
invoices,
summary,
filter,
onFilterChange,
onSelectInvoice,
onClearFilter,
}: AudioInvoiceListProps) {
const hasActiveFilter =
filter.status !== 'all' || filter.contentType !== 'all' || !!filter.search;

return (
<div className="space-y-4" role="region" aria-label="Invoice list">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
aria-hidden="true"
/>
<input
type="text"
placeholder="Search invoices..."
value={filter.search ?? ''}
onChange={(e) => onFilterChange({ ...filter, search: e.target.value })}
className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Search invoices"
/>
</div>

<select
value={filter.status ?? 'all'}
onChange={(e) =>
onFilterChange({ ...filter, status: e.target.value as InvoiceStatus | 'all' })
}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Filter by status"
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>

<select
value={filter.contentType ?? 'all'}
onChange={(e) =>
onFilterChange({ ...filter, contentType: e.target.value as InvoiceContentType | 'all' })
}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Filter by content type"
>
{contentTypeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>

{hasActiveFilter && (
<button
onClick={onClearFilter}
className="inline-flex items-center gap-1 px-3 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-50"
aria-label="Clear all filters"
>
<X size={14} />
Clear
</button>
)}
</div>

<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<span>
Total: <strong>{summary.totalInvoices}</strong>
</span>
<span>
Paid: <strong>{summary.paidCount}</strong>
</span>
<span>
Pending: <strong>{summary.pendingCount}</strong>
</span>
<span>
Failed: <strong>{summary.failedCount}</strong>
</span>
<span>
Refunded: <strong>{summary.refundedCount}</strong>
</span>
<span>
Cancelled: <strong>{summary.cancelledCount}</strong>
</span>
<span>
Revenue: <strong>${summary.totalAmount.toFixed(2)}</strong>
</span>
</div>

{invoices.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No invoices found</p>
{hasActiveFilter && (
<button onClick={onClearFilter} className="mt-2 text-sm text-blue-600 hover:underline">
Clear filters
</button>
)}
</div>
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg" role="list">
{invoices.map((invoice) => (
<li key={invoice.id}>
<button
onClick={() => onSelectInvoice(invoice.id)}
className="w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors flex items-center justify-between gap-4"
aria-label={`View invoice ${invoice.title}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{invoice.title}</p>
<p className="text-xs text-gray-500 mt-0.5">
{invoice.buyerName} &middot; {new Date(invoice.issuedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className="text-sm font-medium text-gray-900">
${invoice.amount.toFixed(2)}
</span>
<AudioInvoiceBadge status={invoice.status} />
</div>
</button>
</li>
))}
</ul>
)}
</div>
);
}
Loading