main: add many things to app :)
Signed-off-by: nfel <nfilsaraee@gmail.com>
This commit is contained in:
parent
91c149e92e
commit
895afc44af
13
.env.example
13
.env.example
@ -1,10 +1,5 @@
|
|||||||
# Application
|
# ZarinPal Configuration
|
||||||
NODE_ENV=production
|
ZARINPAL_MERCHANT_ID=your-merchant-id-here
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# AWS S3 Configuration (for file uploads)
|
# Environment
|
||||||
AWS_REGION=ir-thr-at1
|
NODE_ENV=development
|
||||||
AWS_S3_ENDPOINT=https://s3.ir-thr-at1.arvanstorage.ir
|
|
||||||
AWS_ACCESS_KEY_ID=your_access_key_id
|
|
||||||
AWS_SECRET_ACCESS_KEY=your_secret_access_key
|
|
||||||
AWS_S3_BUCKET=your_bucket_name
|
|
||||||
|
|||||||
@ -22,8 +22,8 @@ COPY . .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Build the application
|
# Build the application with --bun flag to use Bun runtime
|
||||||
RUN bun run build
|
RUN bun --bun run build
|
||||||
|
|
||||||
# Stage 3: Runner
|
# Stage 3: Runner
|
||||||
FROM oven/bun:1-alpine AS runner
|
FROM oven/bun:1-alpine AS runner
|
||||||
@ -56,5 +56,5 @@ EXPOSE 3000
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
# Start the application with Bun
|
# Start the application with Bun runtime
|
||||||
CMD ["bun", "run", "server.js"]
|
CMD ["bun", "--bun", "run", "server.js"]
|
||||||
|
|||||||
@ -68,14 +68,14 @@ export default function AdminAlbumsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">Album Management</h1>
|
<h1 className="text-3xl font-bold text-paper-dark mb-2 border-b-4 border-paper-dark inline-block pb-1">Album Management</h1>
|
||||||
<p className="text-gray-400">Manage your music catalog • {albums.length} albums</p>
|
<p className="text-paper-gray mt-4">Manage your music catalog • {albums.length} albums</p>
|
||||||
</div>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={() => setShowAddModal(true)}
|
onClick={() => setShowAddModal(true)}
|
||||||
className="px-6 py-3 bg-gradient-to-r from-accent-cyan to-accent-cyan/80 hover:from-accent-cyan/90 hover:to-accent-cyan/70 rounded-lg font-semibold text-white transition-all glow-cyan flex items-center gap-2"
|
className="px-6 py-3 bg-paper-brown hover:bg-paper-dark border-2 border-paper-dark font-semibold text-paper-light transition-all shadow-paper-lg flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<FaPlus />
|
<FaPlus />
|
||||||
Add New Album
|
Add New Album
|
||||||
@ -90,28 +90,28 @@ export default function AdminAlbumsPage() {
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
className="glass-effect rounded-xl p-6 border border-white/10"
|
className="paper-card p-6 border-2 border-paper-brown shadow-paper"
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{/* Album Cover */}
|
{/* Album Cover */}
|
||||||
<div className="w-24 h-24 rounded-lg bg-gradient-to-br from-accent-cyan/20 to-accent-orange/20 flex items-center justify-center flex-shrink-0">
|
<div className="w-24 h-24 bg-paper-brown border-2 border-paper-dark flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-xs text-white/50 font-bold text-center px-2">
|
<span className="text-xs text-paper-dark/50 font-bold text-center px-2">
|
||||||
{album.title}
|
{album.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Album Info */}
|
{/* Album Info */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-bold text-white mb-1">{album.title}</h3>
|
<h3 className="text-xl font-bold text-paper-dark mb-1">{album.title}</h3>
|
||||||
<p className="text-sm text-gray-400 mb-2">
|
<p className="text-sm text-paper-gray mb-2">
|
||||||
{album.year} • {album.genre}
|
{album.year} • {album.genre}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mb-3 line-clamp-2">
|
<p className="text-sm text-paper-brown mb-3 line-clamp-2">
|
||||||
{album.description}
|
{album.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
<div className="flex items-center gap-4 text-sm text-paper-gray">
|
||||||
<span>{album.songs.length} tracks</span>
|
<span>{album.songs.length} tracks</span>
|
||||||
<span className="text-accent-orange font-bold">{formatPrice(album.price)}</span>
|
<span className="text-paper-brown font-bold">{formatPrice(album.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -121,7 +121,7 @@ export default function AdminAlbumsPage() {
|
|||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
onClick={() => openEditModal(album)}
|
onClick={() => openEditModal(album)}
|
||||||
className="p-2 bg-accent-cyan/20 hover:bg-accent-cyan/30 rounded-lg text-accent-cyan transition-colors"
|
className="p-2 bg-paper-light hover:bg-paper-sand border-2 border-paper-brown text-paper-brown transition-colors"
|
||||||
aria-label="Edit album"
|
aria-label="Edit album"
|
||||||
>
|
>
|
||||||
<FaEdit />
|
<FaEdit />
|
||||||
@ -130,7 +130,7 @@ export default function AdminAlbumsPage() {
|
|||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
onClick={() => openDeleteModal(album)}
|
onClick={() => openDeleteModal(album)}
|
||||||
className="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors"
|
className="p-2 bg-red-100 hover:bg-red-200 border-2 border-red-400 text-red-700 transition-colors"
|
||||||
aria-label="Delete album"
|
aria-label="Delete album"
|
||||||
>
|
>
|
||||||
<FaTrash />
|
<FaTrash />
|
||||||
@ -139,19 +139,19 @@ export default function AdminAlbumsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track List */}
|
{/* Track List */}
|
||||||
<div className="mt-4 pt-4 border-t border-white/10">
|
<div className="mt-4 pt-4 border-t-2 border-paper-brown">
|
||||||
<p className="text-sm font-semibold text-gray-400 mb-2">Tracks:</p>
|
<p className="text-sm font-semibold text-paper-dark mb-2">Tracks:</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{album.songs.slice(0, 3).map((song, idx) => (
|
{album.songs.slice(0, 3).map((song, idx) => (
|
||||||
<div key={song.id} className="flex justify-between text-sm">
|
<div key={song.id} className="flex justify-between text-sm">
|
||||||
<span className="text-gray-300">
|
<span className="text-paper-dark">
|
||||||
{idx + 1}. {song.title}
|
{idx + 1}. {song.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 font-mono">{song.duration}</span>
|
<span className="text-paper-gray font-mono">{song.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{album.songs.length > 3 && (
|
{album.songs.length > 3 && (
|
||||||
<p className="text-xs text-gray-500">+{album.songs.length - 3} more tracks</p>
|
<p className="text-xs text-paper-gray">+{album.songs.length - 3} more tracks</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { FaDownload, FaSearch } from 'react-icons/fa';
|
import { FaDownload, FaSearch, FaCheck, FaTimes, FaClock } from 'react-icons/fa';
|
||||||
import { useAlbums } from '@/lib/AlbumsContext';
|
import { useAlbums } from '@/lib/AlbumsContext';
|
||||||
import { Purchase } from '@/lib/types';
|
import { Purchase } from '@/lib/types';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice } from '@/lib/utils';
|
||||||
@ -12,29 +12,79 @@ export default function AdminPurchasesPage() {
|
|||||||
const { albums } = useAlbums();
|
const { albums } = useAlbums();
|
||||||
const [purchases, setPurchases] = useState<Purchase[]>([]);
|
const [purchases, setPurchases] = useState<Purchase[]>([]);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'pending' | 'approved' | 'rejected'>('all');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchPurchases = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/purchases');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setPurchases(data.reverse());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching purchases:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedPurchases = localStorage.getItem('purchases');
|
fetchPurchases();
|
||||||
if (savedPurchases) {
|
|
||||||
setPurchases(JSON.parse(savedPurchases).reverse());
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleApprove = async (purchaseId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/purchases/${purchaseId}/approve`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
fetchPurchases();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error approving purchase:', error);
|
||||||
|
alert('Failed to approve purchase');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async (purchaseId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/purchases/${purchaseId}/reject`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
fetchPurchases();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rejecting purchase:', error);
|
||||||
|
alert('Failed to reject purchase');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredPurchases = purchases.filter((purchase) => {
|
const filteredPurchases = purchases.filter((purchase) => {
|
||||||
const album = albums.find((a) => a.id === purchase.albumId);
|
const album = albums.find((a) => a.id === purchase.albumId);
|
||||||
return (
|
const matchesSearch =
|
||||||
album?.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
album?.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
purchase.transactionId.toLowerCase().includes(searchTerm.toLowerCase())
|
purchase.transactionId.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
);
|
purchase.customerName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
purchase.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
const matchesStatus = filterStatus === 'all' || purchase.approvalStatus === filterStatus;
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalRevenue = purchases.reduce((total, purchase) => {
|
const pendingPurchases = purchases.filter(p => p.approvalStatus === 'pending');
|
||||||
|
const approvedPurchases = purchases.filter(p => p.approvalStatus === 'approved');
|
||||||
|
|
||||||
|
const totalRevenue = approvedPurchases.reduce((total, purchase) => {
|
||||||
const album = albums.find((a) => a.id === purchase.albumId);
|
const album = albums.find((a) => a.id === purchase.albumId);
|
||||||
return total + (album?.price || 0);
|
return total + (album?.price || 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
const exportToCSV = () => {
|
const exportToCSV = () => {
|
||||||
const headers = ['Date', 'Time', 'Transaction ID', 'Album', 'Price'];
|
const headers = ['Date', 'Time', 'Transaction ID', 'Album', 'Price', 'Customer', 'Email', 'Phone', 'Status', 'Payment Method'];
|
||||||
const rows = purchases.map((purchase) => {
|
const rows = purchases.map((purchase) => {
|
||||||
const album = albums.find((a) => a.id === purchase.albumId);
|
const album = albums.find((a) => a.id === purchase.albumId);
|
||||||
const date = new Date(purchase.purchaseDate);
|
const date = new Date(purchase.purchaseDate);
|
||||||
@ -43,7 +93,12 @@ export default function AdminPurchasesPage() {
|
|||||||
date.toLocaleTimeString(),
|
date.toLocaleTimeString(),
|
||||||
purchase.transactionId,
|
purchase.transactionId,
|
||||||
album?.title || 'Unknown',
|
album?.title || 'Unknown',
|
||||||
formatPrice(album?.price || 0),
|
album?.price || 0,
|
||||||
|
purchase.customerName || '',
|
||||||
|
purchase.email || '',
|
||||||
|
purchase.phoneNumber || '',
|
||||||
|
purchase.approvalStatus || 'pending',
|
||||||
|
purchase.paymentMethod || 'card-to-card',
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -59,15 +114,40 @@ export default function AdminPurchasesPage() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status?: string) => {
|
||||||
|
const statusLower = (status || 'pending').toLowerCase();
|
||||||
|
if (statusLower === 'approved') {
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 bg-green-100 border border-green-400 text-green-700 text-xs font-semibold">
|
||||||
|
Approved
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (statusLower === 'rejected') {
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 bg-red-100 border border-red-400 text-red-700 text-xs font-semibold">
|
||||||
|
Rejected
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 bg-orange-100 border border-orange-400 text-orange-700 text-xs font-semibold flex items-center gap-1">
|
||||||
|
<FaClock className="text-xs" /> Pending
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-paper-dark mb-2 border-b-4 border-paper-dark inline-block pb-1">Purchase History</h1>
|
<h1 className="text-3xl font-bold text-paper-dark mb-2 border-b-4 border-paper-dark inline-block pb-1">
|
||||||
|
Purchase Management
|
||||||
|
</h1>
|
||||||
<p className="text-paper-gray mt-4">
|
<p className="text-paper-gray mt-4">
|
||||||
{purchases.length} total purchases • {formatPrice(totalRevenue)} revenue
|
{purchases.length} total • {pendingPurchases.length} pending • {formatPrice(totalRevenue)} revenue
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<motion.button
|
<motion.button
|
||||||
@ -81,15 +161,31 @@ export default function AdminPurchasesPage() {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Filters */}
|
||||||
<div className="mb-6">
|
<div className="mb-6 space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['all', 'pending', 'approved', 'rejected'] as const).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => setFilterStatus(status)}
|
||||||
|
className={`px-4 py-2 border-2 font-medium transition-all ${
|
||||||
|
filterStatus === status
|
||||||
|
? 'bg-paper-brown text-paper-light border-paper-dark'
|
||||||
|
: 'bg-paper-light text-paper-dark border-paper-brown hover:bg-paper-sand'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<FaSearch className="absolute left-4 top-1/2 -translate-y-1/2 text-paper-gray" />
|
<FaSearch className="absolute left-4 top-1/2 -translate-y-1/2 text-paper-gray" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
placeholder="Search by album name or transaction ID..."
|
placeholder="Search by album, transaction ID, customer name, or email..."
|
||||||
className="w-full pl-12 pr-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper"
|
className="w-full pl-12 pr-4 py-3 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -101,16 +197,23 @@ export default function AdminPurchasesPage() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="paper-card border-2 border-paper-brown shadow-paper overflow-hidden"
|
className="paper-card border-2 border-paper-brown shadow-paper overflow-hidden"
|
||||||
>
|
>
|
||||||
{filteredPurchases.length > 0 ? (
|
{loading ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<p className="text-paper-gray">Loading purchases...</p>
|
||||||
|
</div>
|
||||||
|
) : filteredPurchases.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-paper-brown/20 border-b-2 border-paper-brown">
|
<thead className="bg-paper-brown/20 border-b-2 border-paper-brown">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left p-4 text-sm font-semibold text-paper-dark">Date & Time</th>
|
<th className="text-left p-4 text-sm font-semibold text-paper-dark">Date & Time</th>
|
||||||
<th className="text-left p-4 text-sm font-semibold text-paper-dark">Transaction ID</th>
|
<th className="text-left p-4 text-sm font-semibold text-paper-dark">Transaction ID</th>
|
||||||
|
<th className="text-left p-4 text-sm font-semibold text-paper-dark">Customer</th>
|
||||||
<th className="text-left p-4 text-sm font-semibold text-paper-dark">Album</th>
|
<th className="text-left p-4 text-sm font-semibold text-paper-dark">Album</th>
|
||||||
<th className="text-left p-4 text-sm font-semibold text-paper-dark">Genre</th>
|
<th className="text-center p-4 text-sm font-semibold text-paper-dark">Payment</th>
|
||||||
|
<th className="text-center p-4 text-sm font-semibold text-paper-dark">Status</th>
|
||||||
<th className="text-right p-4 text-sm font-semibold text-paper-dark">Price</th>
|
<th className="text-right p-4 text-sm font-semibold text-paper-dark">Price</th>
|
||||||
|
<th className="text-center p-4 text-sm font-semibold text-paper-dark">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -120,7 +223,7 @@ export default function AdminPurchasesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.tr
|
<motion.tr
|
||||||
key={purchase.transactionId}
|
key={purchase.id || purchase.transactionId}
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: index * 0.05 }}
|
transition={{ delay: index * 0.05 }}
|
||||||
@ -134,15 +237,52 @@ export default function AdminPurchasesPage() {
|
|||||||
<code className="text-xs text-paper-brown bg-paper-brown/10 px-2 py-1 border border-paper-brown">
|
<code className="text-xs text-paper-brown bg-paper-brown/10 px-2 py-1 border border-paper-brown">
|
||||||
{purchase.transactionId}
|
{purchase.transactionId}
|
||||||
</code>
|
</code>
|
||||||
|
{purchase.txReceipt && (
|
||||||
|
<div className="text-xs text-paper-gray mt-1">
|
||||||
|
Receipt: {purchase.txReceipt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="text-sm text-paper-dark">{purchase.customerName || 'N/A'}</div>
|
||||||
|
<div className="text-xs text-paper-gray">{purchase.email}</div>
|
||||||
|
<div className="text-xs text-paper-gray">{purchase.phoneNumber}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="text-paper-dark font-medium">{album?.title || 'Unknown'}</div>
|
<div className="text-paper-dark font-medium">{album?.title || 'Unknown'}</div>
|
||||||
<div className="text-xs text-paper-gray">{album?.songs.length} tracks</div>
|
<div className="text-xs text-paper-gray">{album?.songs.length} tracks</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-paper-gray text-sm">{album?.genre}</td>
|
<td className="p-4 text-center">
|
||||||
|
<span className="text-xs text-paper-brown bg-paper-brown/10 px-2 py-1 border border-paper-brown">
|
||||||
|
{purchase.paymentMethod === 'ipg' ? 'IPG' : 'Card-to-Card'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-center">
|
||||||
|
{getStatusBadge(purchase.approvalStatus)}
|
||||||
|
</td>
|
||||||
<td className="p-4 text-right">
|
<td className="p-4 text-right">
|
||||||
<span className="text-paper-brown font-bold">{formatPrice(album?.price || 0)}</span>
|
<span className="text-paper-brown font-bold">{formatPrice(album?.price || 0)}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{purchase.approvalStatus === 'pending' && (
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => purchase.id && handleApprove(purchase.id)}
|
||||||
|
className="p-2 bg-green-100 hover:bg-green-200 border-2 border-green-400 text-green-700 transition-colors"
|
||||||
|
title="Approve"
|
||||||
|
>
|
||||||
|
<FaCheck />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => purchase.id && handleReject(purchase.id)}
|
||||||
|
className="p-2 bg-red-100 hover:bg-red-200 border-2 border-red-400 text-red-700 transition-colors"
|
||||||
|
title="Reject"
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -152,7 +292,7 @@ export default function AdminPurchasesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="p-12 text-center">
|
<div className="p-12 text-center">
|
||||||
<p className="text-paper-gray">
|
<p className="text-paper-gray">
|
||||||
{searchTerm ? 'No purchases found matching your search' : 'No purchases yet'}
|
{searchTerm || filterStatus !== 'all' ? 'No purchases found matching your filters' : 'No purchases yet'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -32,25 +32,42 @@ export default function AlbumDetailPage() {
|
|||||||
const foundAlbum = albums.find((a) => a.id === params.id);
|
const foundAlbum = albums.find((a) => a.id === params.id);
|
||||||
setAlbum(foundAlbum || null);
|
setAlbum(foundAlbum || null);
|
||||||
|
|
||||||
// Load purchases
|
// Load purchases from localStorage (legacy)
|
||||||
const savedPurchases = localStorage.getItem('purchases');
|
const savedPurchases = localStorage.getItem('purchases');
|
||||||
if (savedPurchases) {
|
if (savedPurchases) {
|
||||||
const parsedPurchases = JSON.parse(savedPurchases);
|
const parsedPurchases = JSON.parse(savedPurchases);
|
||||||
setPurchases(parsedPurchases);
|
setPurchases(parsedPurchases);
|
||||||
setPurchasedAlbums(parsedPurchases.map((p: Purchase) => p.albumId));
|
|
||||||
}
|
}
|
||||||
}, [params.id]);
|
|
||||||
|
// Fetch approved purchases from API
|
||||||
|
const fetchPurchases = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/purchases');
|
||||||
|
if (response.ok) {
|
||||||
|
const apiPurchases = await response.json();
|
||||||
|
// Only include approved purchases for determining access
|
||||||
|
const approvedPurchases = apiPurchases.filter((p: Purchase) => p.approvalStatus === 'approved');
|
||||||
|
setPurchasedAlbums(approvedPurchases.map((p: Purchase) => p.albumId));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching purchases:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPurchases();
|
||||||
|
}, [params.id, albums]);
|
||||||
|
|
||||||
const handlePurchaseSuccess = (albumId: string, transactionId: string) => {
|
const handlePurchaseSuccess = (albumId: string, transactionId: string) => {
|
||||||
const purchase: Purchase = {
|
const purchase: Purchase = {
|
||||||
albumId,
|
albumId,
|
||||||
transactionId,
|
transactionId,
|
||||||
purchaseDate: new Date(),
|
purchaseDate: new Date(),
|
||||||
|
approvalStatus: 'pending',
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedPurchases = [...purchases, purchase];
|
const updatedPurchases = [...purchases, purchase];
|
||||||
setPurchases(updatedPurchases);
|
setPurchases(updatedPurchases);
|
||||||
setPurchasedAlbums([...purchasedAlbums, albumId]);
|
// Don't add to purchasedAlbums yet - wait for approval
|
||||||
setLatestPurchase(purchase);
|
setLatestPurchase(purchase);
|
||||||
|
|
||||||
localStorage.setItem('purchases', JSON.stringify(updatedPurchases));
|
localStorage.setItem('purchases', JSON.stringify(updatedPurchases));
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
""
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { albumDb } from '@/lib/db';
|
import { albumDb } from '@/lib/db';
|
||||||
import { Album } from '@/lib/types';
|
import { Album } from '@/lib/types';
|
||||||
|
|||||||
73
app/api/payment/initiate/route.ts
Normal file
73
app/api/payment/initiate/route.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { ZarinPal } from 'zarinpal-node-sdk';
|
||||||
|
import { getDatabase } from '@/lib/db';
|
||||||
|
|
||||||
|
const zarinpal = new ZarinPal({
|
||||||
|
merchantId: process.env.ZARINPAL_MERCHANT_ID || 'test-merchant-id',
|
||||||
|
sandbox: process.env.NODE_ENV !== 'production',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { albumId, amount, customerName, email, phoneNumber } = body;
|
||||||
|
|
||||||
|
if (!albumId || !amount) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Album ID and amount are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean phone number: remove +98, spaces, and any non-digits
|
||||||
|
// ZarinPal expects format: 09XXXXXXXXX (11 digits starting with 0)
|
||||||
|
const cleanPhone = phoneNumber.replace(/\D/g, ''); // Remove all non-digits
|
||||||
|
const mobileNumber = cleanPhone.startsWith('98')
|
||||||
|
? '0' + cleanPhone.slice(2) // +98 9390084053 -> 09390084053
|
||||||
|
: cleanPhone.startsWith('9')
|
||||||
|
? '0' + cleanPhone // 9390084053 -> 09390084053
|
||||||
|
: cleanPhone; // Already in correct format
|
||||||
|
|
||||||
|
// Get the base URL for callback
|
||||||
|
const protocol = request.headers.get('x-forwarded-proto') || 'http';
|
||||||
|
const host = request.headers.get('host') || 'localhost:3000';
|
||||||
|
const callback_url = `${protocol}://${host}/payment/callback`;
|
||||||
|
|
||||||
|
// Initiate payment with ZarinPal
|
||||||
|
const response = await zarinpal.payments.create({
|
||||||
|
amount: amount,
|
||||||
|
callback_url: callback_url,
|
||||||
|
description: `Purchase album: ${albumId}`,
|
||||||
|
mobile: mobileNumber,
|
||||||
|
email: email,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.code === 100) {
|
||||||
|
const authority = response.data.authority;
|
||||||
|
|
||||||
|
// Store payment authority in database
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO payment_authorities (authority, albumId, amount, customerName, email, phoneNumber, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'pending')
|
||||||
|
`).run(authority, albumId, amount, customerName, email, mobileNumber);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
authority: authority,
|
||||||
|
paymentUrl: `https://sandbox.zarinpal.com/pg/StartPay/${authority}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to initiate payment', code: response.data?.code },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Payment initiation error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to initiate payment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/api/payment/verify/route.ts
Normal file
104
app/api/payment/verify/route.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { ZarinPal } from 'zarinpal-node-sdk';
|
||||||
|
import { getDatabase } from '@/lib/db';
|
||||||
|
|
||||||
|
const zarinpal = new ZarinPal({
|
||||||
|
merchantId: process.env.ZARINPAL_MERCHANT_ID || 'test-merchant-id',
|
||||||
|
sandbox: process.env.NODE_ENV !== 'production',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { authority } = body;
|
||||||
|
|
||||||
|
if (!authority) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Authority code is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get payment details from database
|
||||||
|
const paymentRecord = db.prepare(`
|
||||||
|
SELECT * FROM payment_authorities WHERE authority = ?
|
||||||
|
`).get(authority) as any;
|
||||||
|
|
||||||
|
if (!paymentRecord) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No matching transaction found for this authority code' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already verified
|
||||||
|
if (paymentRecord.status === 'verified') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
refId: paymentRecord.refId,
|
||||||
|
message: 'Payment already verified',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify payment with ZarinPal
|
||||||
|
const response = await zarinpal.verifications.verify({
|
||||||
|
amount: paymentRecord.amount,
|
||||||
|
authority: authority,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.code === 100 || response.data.code === 101) {
|
||||||
|
const refId = response.data.ref_id;
|
||||||
|
const cardPan = response.data.card_pan;
|
||||||
|
const fee = response.data.fee;
|
||||||
|
|
||||||
|
// Update payment authority status
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE payment_authorities
|
||||||
|
SET status = 'verified', refId = ?, cardPan = ?, fee = ?, verifiedAt = ?
|
||||||
|
WHERE authority = ?
|
||||||
|
`).run(refId, cardPan, fee, Date.now(), authority);
|
||||||
|
|
||||||
|
// Create purchase record (auto-approved for IPG payments)
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO purchases (albumId, transactionId, customerName, email, phoneNumber, txReceipt, purchaseDate, approvalStatus, paymentMethod)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'approved', 'ipg')
|
||||||
|
`).run(
|
||||||
|
paymentRecord.albumId,
|
||||||
|
refId.toString(),
|
||||||
|
paymentRecord.customerName,
|
||||||
|
paymentRecord.email,
|
||||||
|
paymentRecord.phoneNumber,
|
||||||
|
`ZarinPal-${refId}`,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
refId: refId,
|
||||||
|
cardPan: cardPan,
|
||||||
|
fee: fee,
|
||||||
|
message: response.data.code === 101 ? 'Payment already verified' : 'Payment verified successfully',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update payment authority status to failed
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE payment_authorities
|
||||||
|
SET status = 'failed'
|
||||||
|
WHERE authority = ?
|
||||||
|
`).run(authority);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Transaction failed with code: ${response.data.code}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Payment verification error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to verify payment' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/api/purchases/[id]/approve/route.ts
Normal file
46
app/api/purchases/[id]/approve/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const purchaseId = parseInt(id);
|
||||||
|
|
||||||
|
if (isNaN(purchaseId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid purchase ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Update purchase status to approved
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE purchases
|
||||||
|
SET approvalStatus = 'approved'
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(purchaseId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Purchase not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Purchase approved successfully'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error approving purchase:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to approve purchase' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/api/purchases/[id]/reject/route.ts
Normal file
46
app/api/purchases/[id]/reject/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const purchaseId = parseInt(id);
|
||||||
|
|
||||||
|
if (isNaN(purchaseId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid purchase ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Update purchase status to rejected
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE purchases
|
||||||
|
SET approvalStatus = 'rejected'
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(purchaseId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Purchase not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Purchase rejected successfully'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error rejecting purchase:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to reject purchase' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,19 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Vazirmatn } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { CartProvider } from '@/lib/CartContext';
|
import { CartProvider } from '@/lib/CartContext';
|
||||||
import { AdminProvider } from '@/lib/AdminContext';
|
import { AdminProvider } from '@/lib/AdminContext';
|
||||||
import { AlbumsProvider } from '@/lib/AlbumsContext';
|
import { AlbumsProvider } from '@/lib/AlbumsContext';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const vazirmatn = Vazirmatn({
|
||||||
|
subsets: ['latin', 'arabic'],
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Parsa - Progressive Rock Composer',
|
title: 'Podzahr - Parsa Sadatie Music',
|
||||||
description: 'Explore progressive rock albums by composer and producer Parsa. Intricate compositions, powerful instrumentation, and sonic landscapes.',
|
description: 'Explore progressive rock albums by composer and producer Parsa Sadatie (@parsadat). Intricate compositions, powerful instrumentation, and sonic landscapes.',
|
||||||
keywords: ['progressive rock', 'prog rock', 'music', 'composer', 'producer', 'albums'],
|
keywords: ['podzahr', 'parsa sadatie', 'progressive rock', 'prog rock', 'music', 'composer', 'producer', 'albums'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -20,7 +23,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>
|
<body className={vazirmatn.className}>
|
||||||
<AdminProvider>
|
<AdminProvider>
|
||||||
<AlbumsProvider>
|
<AlbumsProvider>
|
||||||
<CartProvider>{children}</CartProvider>
|
<CartProvider>{children}</CartProvider>
|
||||||
|
|||||||
23
app/page.tsx
23
app/page.tsx
@ -19,14 +19,30 @@ export default function Home() {
|
|||||||
const [latestPurchase, setLatestPurchase] = useState<Purchase | null>(null);
|
const [latestPurchase, setLatestPurchase] = useState<Purchase | null>(null);
|
||||||
const [cartOpen, setCartOpen] = useState(false);
|
const [cartOpen, setCartOpen] = useState(false);
|
||||||
|
|
||||||
// Load purchases from localStorage on mount
|
// Load purchases from localStorage and API on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedPurchases = localStorage.getItem('purchases');
|
const savedPurchases = localStorage.getItem('purchases');
|
||||||
if (savedPurchases) {
|
if (savedPurchases) {
|
||||||
const parsedPurchases = JSON.parse(savedPurchases);
|
const parsedPurchases = JSON.parse(savedPurchases);
|
||||||
setPurchases(parsedPurchases);
|
setPurchases(parsedPurchases);
|
||||||
setPurchasedAlbums(parsedPurchases.map((p: Purchase) => p.albumId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch approved purchases from API
|
||||||
|
const fetchPurchases = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/purchases');
|
||||||
|
if (response.ok) {
|
||||||
|
const apiPurchases = await response.json();
|
||||||
|
// Only include approved purchases for determining access
|
||||||
|
const approvedPurchases = apiPurchases.filter((p: Purchase) => p.approvalStatus === 'approved');
|
||||||
|
setPurchasedAlbums(approvedPurchases.map((p: Purchase) => p.albumId));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching purchases:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPurchases();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePlayAlbum = (album: Album) => {
|
const handlePlayAlbum = (album: Album) => {
|
||||||
@ -42,11 +58,12 @@ export default function Home() {
|
|||||||
albumId,
|
albumId,
|
||||||
transactionId,
|
transactionId,
|
||||||
purchaseDate: new Date(),
|
purchaseDate: new Date(),
|
||||||
|
approvalStatus: 'pending',
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedPurchases = [...purchases, purchase];
|
const updatedPurchases = [...purchases, purchase];
|
||||||
setPurchases(updatedPurchases);
|
setPurchases(updatedPurchases);
|
||||||
setPurchasedAlbums([...purchasedAlbums, albumId]);
|
// Don't add to purchasedAlbums yet - wait for approval
|
||||||
setLatestPurchase(purchase);
|
setLatestPurchase(purchase);
|
||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
|
|||||||
138
app/payment/callback/page.tsx
Normal file
138
app/payment/callback/page.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { FaCheckCircle, FaTimesCircle, FaSpinner } from 'react-icons/fa';
|
||||||
|
|
||||||
|
function CallbackContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [status, setStatus] = useState<'verifying' | 'success' | 'failed'>('verifying');
|
||||||
|
const [message, setMessage] = useState('Verifying your payment...');
|
||||||
|
const [refId, setRefId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const authority = searchParams.get('Authority');
|
||||||
|
const statusParam = searchParams.get('Status');
|
||||||
|
|
||||||
|
if (!authority || !statusParam) {
|
||||||
|
setStatus('failed');
|
||||||
|
setMessage('Invalid payment callback');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusParam !== 'OK') {
|
||||||
|
setStatus('failed');
|
||||||
|
setMessage('Payment was cancelled or failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the payment
|
||||||
|
verifyPayment(authority);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const verifyPayment = async (authority: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/payment/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ authority }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
setStatus('success');
|
||||||
|
setMessage(`Payment verified successfully!`);
|
||||||
|
setRefId(data.refId);
|
||||||
|
|
||||||
|
// Redirect to home after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/');
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
setStatus('failed');
|
||||||
|
setMessage(data.error || 'Payment verification failed');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setStatus('failed');
|
||||||
|
setMessage('Failed to verify payment');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-paper-light flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="paper-card max-w-md w-full p-8 border-4 border-paper-dark shadow-paper-lg text-center"
|
||||||
|
>
|
||||||
|
{status === 'verifying' && (
|
||||||
|
<>
|
||||||
|
<FaSpinner className="text-6xl text-paper-brown mx-auto mb-4 animate-spin" />
|
||||||
|
<h1 className="text-2xl font-bold text-paper-dark mb-2">Verifying Payment</h1>
|
||||||
|
<p className="text-paper-gray">{message}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200 }}
|
||||||
|
>
|
||||||
|
<FaCheckCircle className="text-6xl text-green-600 mx-auto mb-4" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-2xl font-bold text-paper-dark mb-2 border-b-4 border-paper-dark inline-block pb-1">
|
||||||
|
Payment Successful!
|
||||||
|
</h1>
|
||||||
|
<p className="text-paper-gray mt-4">{message}</p>
|
||||||
|
{refId && (
|
||||||
|
<div className="mt-4 p-3 bg-paper-light border-2 border-paper-brown">
|
||||||
|
<p className="text-xs text-paper-gray">Reference ID:</p>
|
||||||
|
<p className="text-sm font-mono text-paper-dark font-semibold">{refId}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-paper-gray mt-4">Redirecting you to the home page...</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'failed' && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 200 }}
|
||||||
|
>
|
||||||
|
<FaTimesCircle className="text-6xl text-red-600 mx-auto mb-4" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-2xl font-bold text-paper-dark mb-2 border-b-4 border-paper-dark inline-block pb-1">
|
||||||
|
Payment Failed
|
||||||
|
</h1>
|
||||||
|
<p className="text-paper-gray mt-4">{message}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="mt-6 px-6 py-3 bg-paper-brown hover:bg-paper-dark border-2 border-paper-dark text-paper-light font-semibold transition-all shadow-paper"
|
||||||
|
>
|
||||||
|
Return to Home
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentCallback() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen bg-paper-light flex items-center justify-center">
|
||||||
|
<FaSpinner className="text-6xl text-paper-brown animate-spin" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<CallbackContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -29,7 +29,7 @@ export default function AdminSidebar() {
|
|||||||
<h1 className="text-2xl font-bold text-paper-dark border-b-4 border-paper-dark inline-block pb-1">
|
<h1 className="text-2xl font-bold text-paper-dark border-b-4 border-paper-dark inline-block pb-1">
|
||||||
Admin Panel
|
Admin Panel
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-paper-gray mt-3">Parsa Music Store</p>
|
<p className="text-sm text-paper-gray mt-3">Podzahr</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu */}
|
{/* Menu */}
|
||||||
|
|||||||
@ -43,7 +43,8 @@ export default function Biography() {
|
|||||||
<h1 className="text-4xl md:text-5xl font-bold text-paper-dark mb-2 tracking-tight border-b-4 border-paper-dark inline-block pb-1">
|
<h1 className="text-4xl md:text-5xl font-bold text-paper-dark mb-2 tracking-tight border-b-4 border-paper-dark inline-block pb-1">
|
||||||
{artistBio.name}
|
{artistBio.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-paper-brown mb-6 font-medium mt-4">{artistBio.title}</p>
|
<p className="text-lg text-paper-gray mb-2 mt-3">{artistBio.nickname}</p>
|
||||||
|
<p className="text-xl text-paper-brown mb-6 font-medium">{artistBio.title}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
import { FaTimes, FaTrash, FaShoppingCart } from 'react-icons/fa';
|
import { FaTimes, FaTrash, FaShoppingCart } from 'react-icons/fa';
|
||||||
import { useCart } from '@/lib/CartContext';
|
import { useCart } from '@/lib/CartContext';
|
||||||
import { Album, Purchase } from '@/lib/types';
|
import { Album, Purchase } from '@/lib/types';
|
||||||
|
import { formatPrice } from '@/lib/utils';
|
||||||
import PaymentModal from './PaymentModal';
|
import PaymentModal from './PaymentModal';
|
||||||
import PurchaseSuccessModal from './PurchaseSuccessModal';
|
import PurchaseSuccessModal from './PurchaseSuccessModal';
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ export default function CartSidebar({ isOpen, onClose }: CartSidebarProps) {
|
|||||||
albumId,
|
albumId,
|
||||||
transactionId,
|
transactionId,
|
||||||
purchaseDate: new Date(),
|
purchaseDate: new Date(),
|
||||||
|
approvalStatus: 'pending',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load existing purchases
|
// Load existing purchases
|
||||||
@ -45,7 +47,7 @@ export default function CartSidebar({ isOpen, onClose }: CartSidebarProps) {
|
|||||||
const updatedPurchases = [...existingPurchases, purchase];
|
const updatedPurchases = [...existingPurchases, purchase];
|
||||||
|
|
||||||
localStorage.setItem('purchases', JSON.stringify(updatedPurchases));
|
localStorage.setItem('purchases', JSON.stringify(updatedPurchases));
|
||||||
setPurchasedAlbums([...purchasedAlbums, albumId]);
|
// Don't add to purchasedAlbums yet - wait for approval
|
||||||
setLatestPurchase(purchase);
|
setLatestPurchase(purchase);
|
||||||
|
|
||||||
// Remove from cart
|
// Remove from cart
|
||||||
@ -134,7 +136,7 @@ export default function CartSidebar({ isOpen, onClose }: CartSidebarProps) {
|
|||||||
{item.album.year} • {item.album.songs.length} tracks
|
{item.album.year} • {item.album.songs.length} tracks
|
||||||
</p>
|
</p>
|
||||||
<p className="text-paper-dark font-bold mt-2">
|
<p className="text-paper-dark font-bold mt-2">
|
||||||
${item.album.price}
|
{formatPrice(item.album.price)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -160,7 +162,7 @@ export default function CartSidebar({ isOpen, onClose }: CartSidebarProps) {
|
|||||||
<div className="flex items-center justify-between text-xl p-4 paper-card-dark">
|
<div className="flex items-center justify-between text-xl p-4 paper-card-dark">
|
||||||
<span className="text-paper-light font-semibold">Total</span>
|
<span className="text-paper-light font-semibold">Total</span>
|
||||||
<span className="text-paper-sand font-bold">
|
<span className="text-paper-sand font-bold">
|
||||||
${getCartTotal().toFixed(2)}
|
{formatPrice(getCartTotal())}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -38,9 +38,9 @@ export default function Header({ onCartClick }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-paper-dark tracking-tight">
|
<h1 className="text-xl font-bold text-paper-dark tracking-tight">
|
||||||
Parsa
|
Podzahr
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-paper-brown">Progressive Rock</p>
|
<p className="text-xs text-paper-brown">Parsa Sadatie</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@ -125,18 +125,18 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.9, y: 50 }}
|
exit={{ opacity: 0, scale: 0.9, y: 50 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-6 md:p-12 pointer-events-none"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-sm pointer-events-auto relative">
|
<div className="w-full max-w-sm pointer-events-auto relative my-auto">
|
||||||
{/* Close Button - Top Right */}
|
{/* Close Button - Top Right */}
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute -top-2 -right-2 z-10 p-3 bg-paper-light hover:bg-paper-sand border-2 border-paper-dark transition-colors shadow-paper-lg"
|
className="absolute -top-2 -right-2 z-10 p-2 bg-paper-light hover:bg-paper-sand border-2 border-paper-dark transition-colors shadow-paper-lg"
|
||||||
aria-label="Close player"
|
aria-label="Close player"
|
||||||
>
|
>
|
||||||
<FaTimes className="text-xl text-paper-dark" />
|
<FaTimes className="text-lg text-paper-dark" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
{/* Album Artwork */}
|
{/* Album Artwork */}
|
||||||
@ -144,7 +144,7 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
className="relative aspect-square overflow-hidden mb-6 shadow-paper-lg border-4 border-paper-dark"
|
className="relative aspect-square overflow-hidden mb-3 shadow-paper-lg border-4 border-paper-dark"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-paper-brown"></div>
|
<div className="absolute inset-0 bg-paper-brown"></div>
|
||||||
<div className="absolute inset-0 cardboard-texture"></div>
|
<div className="absolute inset-0 cardboard-texture"></div>
|
||||||
@ -156,8 +156,8 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
|
|
||||||
{/* Preview Badge */}
|
{/* Preview Badge */}
|
||||||
{!isPurchased && (
|
{!isPurchased && (
|
||||||
<div className="absolute top-3 right-3 bg-paper-dark text-paper-light px-3 py-1 border-2 border-paper-brown text-xs font-semibold shadow-paper">
|
<div className="absolute top-2 right-2 bg-paper-dark text-paper-light px-2 py-1 border-2 border-paper-brown text-xs font-semibold shadow-paper">
|
||||||
Preview Mode
|
Preview
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -167,15 +167,15 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
className="text-center mb-6 p-4 paper-card"
|
className="text-center mb-3 p-3 paper-card"
|
||||||
>
|
>
|
||||||
<h2 className="text-xl md:text-2xl font-bold text-paper-dark mb-1">
|
<h2 className="text-lg font-bold text-paper-dark mb-0.5">
|
||||||
{currentSong?.title}
|
{currentSong?.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base text-paper-brown">
|
<p className="text-sm text-paper-brown">
|
||||||
{album.title}
|
{album.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-paper-gray mt-1">
|
<p className="text-xs text-paper-gray mt-0.5">
|
||||||
Track {currentSongIndex + 1} of {album.songs.length}
|
Track {currentSongIndex + 1} of {album.songs.length}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -185,7 +185,7 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
className="mb-5 p-4 paper-card-light"
|
className="mb-3 p-3 paper-card-light"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
@ -212,29 +212,29 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.4 }}
|
transition={{ delay: 0.4 }}
|
||||||
className="flex items-center justify-center gap-6 mb-6 p-4 paper-card"
|
className="flex items-center justify-center gap-4 mb-3 p-3 paper-card"
|
||||||
>
|
>
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={handlePrevious}
|
onClick={handlePrevious}
|
||||||
className="p-3 hover:bg-paper-brown hover:text-paper-light border-2 border-transparent hover:border-paper-dark transition-colors"
|
className="p-2 hover:bg-paper-brown hover:text-paper-light border-2 border-transparent hover:border-paper-dark transition-colors"
|
||||||
aria-label="Previous track"
|
aria-label="Previous track"
|
||||||
>
|
>
|
||||||
<FaStepBackward className="text-xl text-paper-dark" />
|
<FaStepBackward className="text-lg text-paper-dark" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="p-5 bg-paper-dark hover:bg-paper-brown border-2 border-paper-brown transition-all shadow-paper-lg"
|
className="p-4 bg-paper-dark hover:bg-paper-brown border-2 border-paper-brown transition-all shadow-paper-lg"
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
<FaPause className="text-2xl text-paper-light" />
|
<FaPause className="text-xl text-paper-light" />
|
||||||
) : (
|
) : (
|
||||||
<FaPlay className="text-2xl text-paper-light ml-0.5" />
|
<FaPlay className="text-xl text-paper-light ml-0.5" />
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
@ -242,10 +242,10 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="p-3 hover:bg-paper-brown hover:text-paper-light border-2 border-transparent hover:border-paper-dark transition-colors"
|
className="p-2 hover:bg-paper-brown hover:text-paper-light border-2 border-transparent hover:border-paper-dark transition-colors"
|
||||||
aria-label="Next track"
|
aria-label="Next track"
|
||||||
>
|
>
|
||||||
<FaStepForward className="text-xl text-paper-dark" />
|
<FaStepForward className="text-lg text-paper-dark" />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -258,7 +258,7 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
onClick={() => onPurchase(album)}
|
onClick={() => onPurchase(album)}
|
||||||
className="w-full mb-3 py-3 bg-paper-brown hover:bg-paper-dark border-2 border-paper-dark font-semibold text-paper-light text-base transition-all shadow-paper-lg"
|
className="w-full mb-2 py-2.5 bg-paper-brown hover:bg-paper-dark border-2 border-paper-dark font-semibold text-paper-light text-sm transition-all shadow-paper-lg"
|
||||||
>
|
>
|
||||||
Purchase Full Album - {formatPrice(album.price)}
|
Purchase Full Album - {formatPrice(album.price)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@ -272,7 +272,7 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTrackList(!showTrackList)}
|
onClick={() => setShowTrackList(!showTrackList)}
|
||||||
className="w-full py-2.5 bg-paper-sand hover:bg-paper-gray border-2 border-paper-brown transition-colors flex items-center justify-center gap-2 text-paper-dark text-sm font-medium"
|
className="w-full py-2 bg-paper-sand hover:bg-paper-gray border-2 border-paper-brown transition-colors flex items-center justify-center gap-2 text-paper-dark text-xs font-medium"
|
||||||
>
|
>
|
||||||
{showTrackList ? <FaChevronUp className="text-xs" /> : <FaChevronDown className="text-xs" />}
|
{showTrackList ? <FaChevronUp className="text-xs" /> : <FaChevronDown className="text-xs" />}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
@ -289,7 +289,7 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="mt-3 max-h-52 overflow-y-auto bg-paper-light border-2 border-paper-brown p-2">
|
<div className="mt-2 max-h-40 overflow-y-auto bg-paper-light border-2 border-paper-brown p-1.5">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{album.songs.map((song, index) => (
|
{album.songs.map((song, index) => (
|
||||||
<button
|
<button
|
||||||
@ -299,18 +299,18 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-3 py-2 transition-all border-2 ${
|
className={`w-full text-left px-2 py-1.5 transition-all border ${
|
||||||
index === currentSongIndex
|
index === currentSongIndex
|
||||||
? 'bg-paper-brown text-paper-light border-paper-dark'
|
? 'bg-paper-brown text-paper-light border-paper-dark'
|
||||||
: 'hover:bg-paper-sand text-paper-dark border-transparent'
|
: 'hover:bg-paper-sand text-paper-dark border-transparent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-paper-gray font-mono w-5">
|
<span className="text-xs text-paper-gray font-mono w-4">
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium">{song.title}</span>
|
<span className="text-xs font-medium">{song.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-paper-gray font-mono">{song.duration}</span>
|
<span className="text-xs text-paper-gray font-mono">{song.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { FaTimes, FaCreditCard, FaLock } from 'react-icons/fa';
|
import { FaTimes, FaCreditCard, FaLock, FaUniversity } from 'react-icons/fa';
|
||||||
import { Album } from '@/lib/types';
|
import { Album } from '@/lib/types';
|
||||||
import { formatPrice } from '@/lib/utils';
|
import { formatPrice } from '@/lib/utils';
|
||||||
|
|
||||||
|
type PaymentMethod = 'ipg' | 'card-to-card';
|
||||||
|
|
||||||
interface PaymentModalProps {
|
interface PaymentModalProps {
|
||||||
album: Album | null;
|
album: Album | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@ -13,6 +15,7 @@ interface PaymentModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PaymentModal({ album, onClose, onSuccess }: PaymentModalProps) {
|
export default function PaymentModal({ album, onClose, onSuccess }: PaymentModalProps) {
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>('ipg');
|
||||||
const [cardNumber, setCardNumber] = useState('');
|
const [cardNumber, setCardNumber] = useState('');
|
||||||
const [cardName, setCardName] = useState('');
|
const [cardName, setCardName] = useState('');
|
||||||
const [phoneNumber, setPhoneNumber] = useState('');
|
const [phoneNumber, setPhoneNumber] = useState('');
|
||||||
@ -28,10 +31,22 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatPhoneNumber = (value: string) => {
|
const formatPhoneNumber = (value: string) => {
|
||||||
const numbers = value.replace(/\D/g, '');
|
// Remove all non-digits
|
||||||
if (numbers.length <= 3) return numbers;
|
let numbers = value.replace(/\D/g, '');
|
||||||
if (numbers.length <= 6) return `(${numbers.slice(0, 3)}) ${numbers.slice(3)}`;
|
|
||||||
return `(${numbers.slice(0, 3)}) ${numbers.slice(3, 6)}-${numbers.slice(6, 10)}`;
|
// Remove leading 98 if user types it (we'll add +98 prefix)
|
||||||
|
if (numbers.startsWith('98')) {
|
||||||
|
numbers = numbers.slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to 10 digits (Iranian mobile number length)
|
||||||
|
numbers = numbers.slice(0, 10);
|
||||||
|
|
||||||
|
// Format as +98 XXXX XXX XXXX
|
||||||
|
if (numbers.length === 0) return '';
|
||||||
|
if (numbers.length <= 4) return `+98 ${numbers}`;
|
||||||
|
if (numbers.length <= 7) return `+98 ${numbers.slice(0, 4)} ${numbers.slice(4)}`;
|
||||||
|
return `+98 ${numbers.slice(0, 4)} ${numbers.slice(4, 7)} ${numbers.slice(7)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@ -39,18 +54,17 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (cardNumber.replace(/\s/g, '').length !== 16) {
|
|
||||||
setError('Invalid card number');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cardName.trim()) {
|
if (!cardName.trim()) {
|
||||||
setError('Name is required');
|
setError('Name is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phoneNumber.replace(/\D/g, '').length < 10) {
|
// Validate Iranian phone number (should have 10 digits after +98)
|
||||||
setError('Invalid phone number');
|
const phoneDigits = phoneNumber.replace(/\D/g, '');
|
||||||
|
// Remove the country code (98) if present to get local number
|
||||||
|
const localNumber = phoneDigits.startsWith('98') ? phoneDigits.slice(2) : phoneDigits;
|
||||||
|
if (localNumber.length !== 10 || !localNumber.startsWith('9')) {
|
||||||
|
setError('Invalid phone number. Must start with 9 and have 10 digits');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,9 +74,16 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!txReceipt.trim()) {
|
// Validate based on payment method
|
||||||
setError('Transaction receipt is required');
|
if (paymentMethod === 'card-to-card') {
|
||||||
return;
|
if (cardNumber.replace(/\s/g, '').length !== 16) {
|
||||||
|
setError('Invalid card number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!txReceipt.trim()) {
|
||||||
|
setError('Transaction receipt is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!album) return;
|
if (!album) return;
|
||||||
@ -70,10 +91,35 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the provided transaction receipt as the ID
|
// Handle IPG payment
|
||||||
|
if (paymentMethod === 'ipg') {
|
||||||
|
const response = await fetch('/api/payment/initiate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
albumId: album.id,
|
||||||
|
amount: album.price,
|
||||||
|
customerName: cardName.trim(),
|
||||||
|
email: email.trim(),
|
||||||
|
phoneNumber: phoneNumber,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to initiate payment');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to ZarinPal payment gateway
|
||||||
|
window.location.href = data.paymentUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle card-to-card payment
|
||||||
const transactionId = txReceipt.trim() || 'TXN-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9).toUpperCase();
|
const transactionId = txReceipt.trim() || 'TXN-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9).toUpperCase();
|
||||||
|
|
||||||
// Create purchase via API
|
// Create purchase via API (pending approval for card-to-card)
|
||||||
const response = await fetch('/api/purchases', {
|
const response = await fetch('/api/purchases', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -85,6 +131,8 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
phoneNumber: phoneNumber,
|
phoneNumber: phoneNumber,
|
||||||
txReceipt: txReceipt.trim(),
|
txReceipt: txReceipt.trim(),
|
||||||
purchaseDate: new Date().getTime(),
|
purchaseDate: new Date().getTime(),
|
||||||
|
approvalStatus: 'pending',
|
||||||
|
paymentMethod: 'card-to-card',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -110,7 +158,7 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 bg-paper-dark/90 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -118,53 +166,90 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0.9, opacity: 0 }}
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="glass-effect rounded-2xl max-w-md w-full p-8 border-2 border-accent-cyan/30"
|
className="paper-card max-w-md w-full p-6 border-4 border-paper-dark shadow-paper-lg"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan to-accent-orange">
|
<h2 className="text-2xl font-bold text-paper-dark border-b-4 border-paper-dark inline-block pb-1">
|
||||||
Purchase Album
|
Purchase Album
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
className="p-2 hover:bg-paper-brown hover:text-paper-light border-2 border-paper-brown transition-colors"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<FaTimes className="text-xl text-gray-400" />
|
<FaTimes className="text-lg text-paper-dark" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Album Info */}
|
{/* Album Info */}
|
||||||
<div className="mb-6 p-4 bg-white/5 rounded-lg">
|
<div className="mb-4 p-3 bg-paper-light border-2 border-paper-brown">
|
||||||
<h3 className="font-semibold text-white">{album.title}</h3>
|
<h3 className="font-semibold text-paper-dark">{album.title}</h3>
|
||||||
<p className="text-sm text-gray-400">{album.songs.length} tracks • {album.genre}</p>
|
<p className="text-sm text-paper-gray">{album.songs.length} tracks • {album.genre}</p>
|
||||||
<p className="text-2xl font-bold text-accent-orange mt-2">{formatPrice(album.price)}</p>
|
<p className="text-xl font-bold text-paper-brown mt-1">{formatPrice(album.price)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Method Selector */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-paper-dark mb-2">
|
||||||
|
Payment Method / روش پرداخت
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaymentMethod('ipg')}
|
||||||
|
className={`p-3 border-2 transition-all ${
|
||||||
|
paymentMethod === 'ipg'
|
||||||
|
? 'bg-paper-brown text-paper-light border-paper-dark shadow-paper'
|
||||||
|
: 'bg-paper-light text-paper-dark border-paper-brown hover:bg-paper-sand'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FaUniversity className="text-lg mx-auto mb-1" />
|
||||||
|
<div className="text-xs font-medium">IPG</div>
|
||||||
|
<div className="text-xs">درگاه پرداخت</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaymentMethod('card-to-card')}
|
||||||
|
className={`p-3 border-2 transition-all ${
|
||||||
|
paymentMethod === 'card-to-card'
|
||||||
|
? 'bg-paper-brown text-paper-light border-paper-dark shadow-paper'
|
||||||
|
: 'bg-paper-light text-paper-dark border-paper-brown hover:bg-paper-sand'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FaCreditCard className="text-lg mx-auto mb-1" />
|
||||||
|
<div className="text-xs font-medium">Card to Card</div>
|
||||||
|
<div className="text-xs">کارت به کارت</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Form */}
|
{/* Payment Form */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
{/* Card Number */}
|
{/* Show Card Number only for card-to-card */}
|
||||||
<div>
|
{paymentMethod === 'card-to-card' && (
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<div>
|
||||||
<FaCreditCard className="inline mr-2" />
|
<label className="block text-sm font-medium text-paper-dark mb-1">
|
||||||
Card Number
|
<FaCreditCard className="inline mr-2" />
|
||||||
</label>
|
Card Number
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
value={cardNumber}
|
type="text"
|
||||||
onChange={(e) => {
|
value={cardNumber}
|
||||||
const formatted = formatCardNumber(e.target.value.replace(/\D/g, '').slice(0, 16));
|
onChange={(e) => {
|
||||||
setCardNumber(formatted);
|
const formatted = formatCardNumber(e.target.value.replace(/\D/g, '').slice(0, 16));
|
||||||
}}
|
setCardNumber(formatted);
|
||||||
placeholder="1234 5678 9012 3456"
|
}}
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500"
|
placeholder="1234 5678 9012 3456"
|
||||||
required
|
className="w-full px-3 py-2 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper text-sm"
|
||||||
/>
|
required
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Card Name */}
|
{/* Full Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-paper-dark mb-1">
|
||||||
Full Name
|
Full Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -172,15 +257,15 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
value={cardName}
|
value={cardName}
|
||||||
onChange={(e) => setCardName(e.target.value)}
|
onChange={(e) => setCardName(e.target.value)}
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500"
|
className="w-full px-3 py-2 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone Number */}
|
{/* Phone Number */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-paper-dark mb-1">
|
||||||
Phone Number
|
Phone Number / شماره تماس
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
@ -189,15 +274,15 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
const formatted = formatPhoneNumber(e.target.value);
|
const formatted = formatPhoneNumber(e.target.value);
|
||||||
setPhoneNumber(formatted);
|
setPhoneNumber(formatted);
|
||||||
}}
|
}}
|
||||||
placeholder="(123) 456-7890"
|
placeholder="+98 9390 084 053"
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500"
|
className="w-full px-3 py-2 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-paper-dark mb-1">
|
||||||
Email Address
|
Email Address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -205,32 +290,34 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="john@example.com"
|
placeholder="john@example.com"
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500"
|
className="w-full px-3 py-2 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transaction Receipt */}
|
{/* Transaction Receipt - only for card-to-card */}
|
||||||
<div>
|
{paymentMethod === 'card-to-card' && (
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<div>
|
||||||
Transaction Receipt / ID
|
<label className="block text-sm font-medium text-paper-dark mb-1">
|
||||||
</label>
|
Transaction Receipt / رسید پرداخت
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<input
|
||||||
value={txReceipt}
|
type="text"
|
||||||
onChange={(e) => setTxReceipt(e.target.value)}
|
value={txReceipt}
|
||||||
placeholder="TXN-123456789"
|
onChange={(e) => setTxReceipt(e.target.value)}
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500"
|
placeholder="TXN-123456789"
|
||||||
required
|
className="w-full px-3 py-2 bg-paper-light border-2 border-paper-brown focus:border-paper-dark focus:outline-none text-paper-dark placeholder-paper-gray shadow-paper text-sm"
|
||||||
/>
|
required
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm"
|
className="p-2 bg-red-100 border-2 border-red-400 text-red-700 text-sm"
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -240,23 +327,23 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
className="w-full py-4 bg-gradient-to-r from-accent-orange to-accent-orange/80 hover:from-accent-orange/90 hover:to-accent-orange/70 rounded-lg font-semibold text-white transition-all glow-orange disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
className="w-full py-3 bg-paper-brown hover:bg-paper-dark border-2 border-paper-dark font-semibold text-paper-light transition-all shadow-paper-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
{processing ? (
|
{processing ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-paper-light"></div>
|
||||||
Processing...
|
Processing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FaLock />
|
<FaLock />
|
||||||
Complete Purchase - {formatPrice(album.price)}
|
{paymentMethod === 'ipg' ? 'Proceed to Payment Gateway' : `Complete Purchase - ${formatPrice(album.price)}`}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Security Notice */}
|
{/* Security Notice */}
|
||||||
<p className="text-xs text-gray-500 text-center mt-4">
|
<p className="text-xs text-paper-gray text-center mt-2">
|
||||||
<FaLock className="inline mr-1" />
|
<FaLock className="inline mr-1" />
|
||||||
This is a demo payment form. No real charges will be made.
|
This is a demo payment form. No real charges will be made.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -18,12 +18,12 @@ export default function PurchaseSuccessModal({ show, album, purchase, onClose }:
|
|||||||
const handleDownloadReceipt = () => {
|
const handleDownloadReceipt = () => {
|
||||||
// Create a simple text receipt
|
// Create a simple text receipt
|
||||||
const receipt = `
|
const receipt = `
|
||||||
PURCHASE RECEIPT
|
PURCHASE RECEIPT / رسید خرید
|
||||||
================
|
================
|
||||||
|
|
||||||
Status: PENDING APPROVAL
|
Status: PENDING APPROVAL / در انتظار تایید
|
||||||
Album: ${album.title}
|
Album: ${album.title}
|
||||||
Artist: Parsa
|
Artist: Parsa Sadatie (@parsadat)
|
||||||
Amount: ${formatPrice(album.price)}
|
Amount: ${formatPrice(album.price)}
|
||||||
Transaction ID: ${purchase.transactionId}
|
Transaction ID: ${purchase.transactionId}
|
||||||
Date: ${new Date(purchase.purchaseDate).toLocaleString()}
|
Date: ${new Date(purchase.purchaseDate).toLocaleString()}
|
||||||
@ -31,9 +31,11 @@ Date: ${new Date(purchase.purchaseDate).toLocaleString()}
|
|||||||
Tracks Included:
|
Tracks Included:
|
||||||
${album.songs.map((song, idx) => `${idx + 1}. ${song.title} (${song.duration})`).join('\n')}
|
${album.songs.map((song, idx) => `${idx + 1}. ${song.title} (${song.duration})`).join('\n')}
|
||||||
|
|
||||||
Thank you for your purchase!
|
Thank you for your purchase! / از خرید شما متشکریم!
|
||||||
Your order is pending admin approval.
|
Your order is pending admin approval.
|
||||||
|
سفارش شما در انتظار تایید مدیر است.
|
||||||
You will receive access to this album after confirmation.
|
You will receive access to this album after confirmation.
|
||||||
|
پس از تایید به آلبوم دسترسی خواهید داشت.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const blob = new Blob([receipt], { type: 'text/plain' });
|
const blob = new Blob([receipt], { type: 'text/plain' });
|
||||||
@ -54,7 +56,7 @@ You will receive access to this album after confirmation.
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
className="fixed inset-0 bg-paper-dark/90 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -62,55 +64,55 @@ You will receive access to this album after confirmation.
|
|||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="glass-effect rounded-2xl max-w-lg w-full p-8 border-2 border-accent-cyan/30"
|
className="paper-card max-w-lg w-full p-8 border-4 border-paper-dark shadow-paper-lg"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="flex items-start justify-between mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-3 bg-accent-orange/20 rounded-full">
|
<div className="p-3 bg-paper-brown border-2 border-paper-dark">
|
||||||
<FaClock className="text-3xl text-accent-orange" />
|
<FaClock className="text-3xl text-paper-light" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white">
|
<h2 className="text-2xl font-bold text-paper-dark border-b-4 border-paper-dark inline-block pb-1">
|
||||||
Purchase Pending Approval
|
Purchase Pending Approval
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400">Please wait for admin confirmation</p>
|
<p className="text-sm text-paper-gray mt-2">در انتظار تایید مدیر</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
className="p-2 hover:bg-paper-brown hover:text-paper-light border-2 border-paper-brown transition-colors"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<FaTimes className="text-xl text-gray-400" />
|
<FaTimes className="text-xl text-paper-dark" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Receipt Details */}
|
{/* Receipt Details */}
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
<div className="p-4 bg-white/5 rounded-lg space-y-3">
|
<div className="p-4 bg-paper-light border-2 border-paper-brown space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-400">Album</span>
|
<span className="text-paper-gray">Album / آلبوم</span>
|
||||||
<span className="text-white font-semibold">{album.title}</span>
|
<span className="text-paper-dark font-semibold">{album.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-400">Artist</span>
|
<span className="text-paper-gray">Artist / هنرمند</span>
|
||||||
<span className="text-white font-semibold">Parsa</span>
|
<span className="text-paper-dark font-semibold">Parsa Sadatie</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-400">Tracks</span>
|
<span className="text-paper-gray">Tracks / تعداد آهنگ</span>
|
||||||
<span className="text-white font-semibold">{album.songs.length} songs</span>
|
<span className="text-paper-dark font-semibold">{album.songs.length} songs</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-white/10 pt-3 flex justify-between">
|
<div className="border-t-2 border-paper-brown pt-3 flex justify-between">
|
||||||
<span className="text-gray-400">Amount</span>
|
<span className="text-paper-gray">Amount / مبلغ</span>
|
||||||
<span className="text-accent-orange font-bold text-xl">{formatPrice(album.price)}</span>
|
<span className="text-paper-brown font-bold text-xl">{formatPrice(album.price)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-accent-cyan/10 border border-accent-cyan/30 rounded-lg">
|
<div className="p-4 bg-paper-sand border-2 border-paper-dark">
|
||||||
<p className="text-xs text-gray-400 mb-1">Transaction ID</p>
|
<p className="text-xs text-paper-gray mb-1">Transaction ID / شناسه تراکنش</p>
|
||||||
<p className="text-sm font-mono text-accent-cyan">{purchase.transactionId}</p>
|
<p className="text-sm font-mono text-paper-dark font-semibold">{purchase.transactionId}</p>
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-paper-brown mt-2">
|
||||||
{new Date(purchase.purchaseDate).toLocaleString()}
|
{new Date(purchase.purchaseDate).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -120,25 +122,26 @@ You will receive access to this album after confirmation.
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadReceipt}
|
onClick={handleDownloadReceipt}
|
||||||
className="w-full py-3 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg font-semibold text-white transition-all flex items-center justify-center gap-2"
|
className="w-full py-3 bg-paper-light hover:bg-paper-sand border-2 border-paper-brown font-semibold text-paper-dark transition-all shadow-paper flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<FaDownload />
|
<FaDownload />
|
||||||
Download Receipt
|
Download Receipt / دریافت رسید
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-full py-3 bg-gradient-to-r from-accent-cyan to-accent-cyan/80 hover:from-accent-cyan/90 hover:to-accent-cyan/70 rounded-lg font-semibold text-white transition-all glow-cyan"
|
className="w-full py-3 bg-paper-brown hover:bg-paper-dark border-2 border-paper-dark font-semibold text-paper-light transition-all shadow-paper-lg"
|
||||||
>
|
>
|
||||||
OK, Got It
|
OK, Got It / متوجه شدم
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<p className="text-xs text-gray-500 text-center mt-6">
|
<p className="text-xs text-paper-gray text-center mt-6">
|
||||||
Your purchase will be reviewed by an admin. You will receive access after approval.
|
Your purchase will be reviewed by an admin. You will receive access after approval.<br />
|
||||||
|
سفارش شما توسط مدیر بررسی میشود. پس از تایید به آلبوم دسترسی خواهید داشت.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { Album } from './types';
|
import { Album } from './types';
|
||||||
|
|
||||||
export const artistBio = {
|
export const artistBio = {
|
||||||
name: "Parsa",
|
name: "Parsa Sadatie",
|
||||||
|
nickname: "@parsadat",
|
||||||
title: "Progressive Rock Composer & Producer",
|
title: "Progressive Rock Composer & Producer",
|
||||||
bio: `A visionary composer and producer specializing in progressive rock and rock music. With a career spanning over a decade, Parsa has crafted intricate soundscapes that blend complex time signatures, soaring melodies, and powerful instrumentation. His work pushes the boundaries of rock music, drawing inspiration from classic prog rock pioneers while incorporating modern production techniques.
|
bio: `A visionary composer and producer specializing in progressive rock and rock music. With a career spanning over a decade, Parsa Sadatie has crafted intricate soundscapes that blend complex time signatures, soaring melodies, and powerful instrumentation. His work pushes the boundaries of rock music, drawing inspiration from classic prog rock pioneers while incorporating modern production techniques.
|
||||||
|
|
||||||
Each album is a journey through sonic landscapes, featuring elaborate compositions that challenge and reward the listener. From epic 20-minute suites to more concise rock anthems, every piece is meticulously crafted with attention to detail and emotional depth.`,
|
Each album is a journey through sonic landscapes, featuring elaborate compositions that challenge and reward the listener. From epic 20-minute suites to more concise rock anthems, every piece is meticulously crafted with attention to detail and emotional depth.`,
|
||||||
image: "/artist-photo.jpg", // Placeholder
|
image: "/artist-photo.jpg", // Placeholder
|
||||||
|
|||||||
158
lib/db.ts
158
lib/db.ts
@ -1,25 +1,40 @@
|
|||||||
import { Database } from 'bun:sqlite';
|
import { Database } from "bun:sqlite";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
import { albums as initialAlbums } from './data';
|
import { albums as initialAlbums } from "./data";
|
||||||
import { Album, Purchase } from './types';
|
import { Album, Purchase } from "./types";
|
||||||
|
|
||||||
// Database path
|
// Database path
|
||||||
const dbPath = path.join(process.cwd(), 'data', 'parsa.db');
|
const dbPath = path.join(process.cwd(), "data", "parsa.db");
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
let db: Database;
|
let db: any;
|
||||||
|
|
||||||
export function getDatabase(): Database {
|
function createDatabase() {
|
||||||
|
// Use Bun's native SQLite if available, otherwise use better-sqlite3
|
||||||
|
const isBun = typeof Bun !== "undefined";
|
||||||
|
const { Database } = require("bun:sqlite");
|
||||||
|
return new Database(dbPath, { create: true });
|
||||||
|
|
||||||
|
if (isBun) {
|
||||||
|
const { Database } = require("bun:sqlite");
|
||||||
|
return new Database(dbPath, { create: true });
|
||||||
|
} else {
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
return new Database(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDatabase(): any {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
// Create data directory if it doesn't exist
|
// Create data directory if it doesn't exist
|
||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const dataDir = path.join(process.cwd(), 'data');
|
const dataDir = path.join(process.cwd(), "data");
|
||||||
if (!fs.existsSync(dataDir)) {
|
if (!fs.existsSync(dataDir)) {
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
db = new Database(dbPath, { create: true });
|
db = createDatabase();
|
||||||
db.exec('PRAGMA journal_mode = WAL');
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
initializeDatabase();
|
initializeDatabase();
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
@ -56,19 +71,58 @@ function initializeDatabase() {
|
|||||||
phoneNumber TEXT,
|
phoneNumber TEXT,
|
||||||
txReceipt TEXT,
|
txReceipt TEXT,
|
||||||
purchaseDate INTEGER NOT NULL,
|
purchaseDate INTEGER NOT NULL,
|
||||||
|
approvalStatus TEXT DEFAULT 'pending',
|
||||||
|
paymentMethod TEXT DEFAULT 'card-to-card',
|
||||||
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE
|
FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Create payment authorities table for ZarinPal tracking
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_authorities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
authority TEXT NOT NULL UNIQUE,
|
||||||
|
albumId TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
customerName TEXT,
|
||||||
|
email TEXT,
|
||||||
|
phoneNumber TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
refId TEXT,
|
||||||
|
cardPan TEXT,
|
||||||
|
fee INTEGER,
|
||||||
|
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
|
||||||
|
verifiedAt INTEGER,
|
||||||
|
FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add columns if they don't exist (migration)
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE purchases ADD COLUMN approvalStatus TEXT DEFAULT 'pending'`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE purchases ADD COLUMN paymentMethod TEXT DEFAULT 'card-to-card'`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists
|
||||||
|
}
|
||||||
|
|
||||||
// Create indexes
|
// Create indexes
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_purchases_albumId ON purchases(albumId);
|
CREATE INDEX IF NOT EXISTS idx_purchases_albumId ON purchases(albumId);
|
||||||
CREATE INDEX IF NOT EXISTS idx_purchases_transactionId ON purchases(transactionId);
|
CREATE INDEX IF NOT EXISTS idx_purchases_transactionId ON purchases(transactionId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchases_approvalStatus ON purchases(approvalStatus);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_authorities_authority ON payment_authorities(authority);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_payment_authorities_status ON payment_authorities(status);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Seed initial data if albums table is empty
|
// Seed initial data if albums table is empty
|
||||||
const count = db.prepare('SELECT COUNT(*) as count FROM albums').get() as { count: number };
|
const count = db.prepare("SELECT COUNT(*) as count FROM albums").get() as {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
if (count.count === 0) {
|
if (count.count === 0) {
|
||||||
seedInitialData();
|
seedInitialData();
|
||||||
}
|
}
|
||||||
@ -93,7 +147,7 @@ function seedInitialData() {
|
|||||||
album.tag,
|
album.tag,
|
||||||
album.format,
|
album.format,
|
||||||
album.bitrate,
|
album.bitrate,
|
||||||
JSON.stringify(album.songs)
|
JSON.stringify(album.songs),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -105,7 +159,7 @@ function seedInitialData() {
|
|||||||
export const albumDb = {
|
export const albumDb = {
|
||||||
getAll(): Album[] {
|
getAll(): Album[] {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const rows = db.prepare('SELECT * FROM albums ORDER BY year DESC').all();
|
const rows = db.prepare("SELECT * FROM albums ORDER BY year DESC").all();
|
||||||
return rows.map((row: any) => ({
|
return rows.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
songs: JSON.parse(row.songs),
|
songs: JSON.parse(row.songs),
|
||||||
@ -114,7 +168,7 @@ export const albumDb = {
|
|||||||
|
|
||||||
getById(id: string): Album | null {
|
getById(id: string): Album | null {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const row = db.prepare('SELECT * FROM albums WHERE id = ?').get(id) as any;
|
const row = db.prepare("SELECT * FROM albums WHERE id = ?").get(id) as any;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
@ -124,10 +178,12 @@ export const albumDb = {
|
|||||||
|
|
||||||
create(album: Album): void {
|
create(album: Album): void {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
db.prepare(
|
||||||
|
`
|
||||||
INSERT INTO albums (id, title, coverImage, year, genre, description, price, tag, format, bitrate, songs)
|
INSERT INTO albums (id, title, coverImage, year, genre, description, price, tag, format, bitrate, songs)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`,
|
||||||
|
).run(
|
||||||
album.id,
|
album.id,
|
||||||
album.title,
|
album.title,
|
||||||
album.coverImage,
|
album.coverImage,
|
||||||
@ -138,17 +194,19 @@ export const albumDb = {
|
|||||||
album.tag,
|
album.tag,
|
||||||
album.format,
|
album.format,
|
||||||
album.bitrate,
|
album.bitrate,
|
||||||
JSON.stringify(album.songs)
|
JSON.stringify(album.songs),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
update(id: string, album: Album): void {
|
update(id: string, album: Album): void {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare(`
|
db.prepare(
|
||||||
|
`
|
||||||
UPDATE albums
|
UPDATE albums
|
||||||
SET title = ?, coverImage = ?, year = ?, genre = ?, description = ?, price = ?, tag = ?, format = ?, bitrate = ?, songs = ?, updatedAt = strftime('%s', 'now')
|
SET title = ?, coverImage = ?, year = ?, genre = ?, description = ?, price = ?, tag = ?, format = ?, bitrate = ?, songs = ?, updatedAt = strftime('%s', 'now')
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`,
|
||||||
|
).run(
|
||||||
album.title,
|
album.title,
|
||||||
album.coverImage,
|
album.coverImage,
|
||||||
album.year,
|
album.year,
|
||||||
@ -159,13 +217,13 @@ export const albumDb = {
|
|||||||
album.format,
|
album.format,
|
||||||
album.bitrate,
|
album.bitrate,
|
||||||
JSON.stringify(album.songs),
|
JSON.stringify(album.songs),
|
||||||
id
|
id,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
delete(id: string): void {
|
delete(id: string): void {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare('DELETE FROM albums WHERE id = ?').run(id);
|
db.prepare("DELETE FROM albums WHERE id = ?").run(id);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -173,7 +231,9 @@ export const albumDb = {
|
|||||||
export const purchaseDb = {
|
export const purchaseDb = {
|
||||||
getAll(): Purchase[] {
|
getAll(): Purchase[] {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const rows = db.prepare('SELECT * FROM purchases ORDER BY purchaseDate DESC').all();
|
const rows = db
|
||||||
|
.prepare("SELECT * FROM purchases ORDER BY purchaseDate DESC")
|
||||||
|
.all();
|
||||||
return rows.map((row: any) => ({
|
return rows.map((row: any) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
albumId: row.albumId,
|
albumId: row.albumId,
|
||||||
@ -183,12 +243,18 @@ export const purchaseDb = {
|
|||||||
phoneNumber: row.phoneNumber,
|
phoneNumber: row.phoneNumber,
|
||||||
txReceipt: row.txReceipt,
|
txReceipt: row.txReceipt,
|
||||||
purchaseDate: new Date(row.purchaseDate),
|
purchaseDate: new Date(row.purchaseDate),
|
||||||
|
approvalStatus: row.approvalStatus,
|
||||||
|
paymentMethod: row.paymentMethod,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
getByAlbumId(albumId: string): Purchase[] {
|
getByAlbumId(albumId: string): Purchase[] {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const rows = db.prepare('SELECT * FROM purchases WHERE albumId = ? ORDER BY purchaseDate DESC').all(albumId);
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM purchases WHERE albumId = ? ORDER BY purchaseDate DESC",
|
||||||
|
)
|
||||||
|
.all(albumId);
|
||||||
return rows.map((row: any) => ({
|
return rows.map((row: any) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
albumId: row.albumId,
|
albumId: row.albumId,
|
||||||
@ -198,12 +264,16 @@ export const purchaseDb = {
|
|||||||
phoneNumber: row.phoneNumber,
|
phoneNumber: row.phoneNumber,
|
||||||
txReceipt: row.txReceipt,
|
txReceipt: row.txReceipt,
|
||||||
purchaseDate: new Date(row.purchaseDate),
|
purchaseDate: new Date(row.purchaseDate),
|
||||||
|
approvalStatus: row.approvalStatus,
|
||||||
|
paymentMethod: row.paymentMethod,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
getByTransactionId(transactionId: string): Purchase | null {
|
getByTransactionId(transactionId: string): Purchase | null {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const row = db.prepare('SELECT * FROM purchases WHERE transactionId = ?').get(transactionId) as any;
|
const row = db
|
||||||
|
.prepare("SELECT * FROM purchases WHERE transactionId = ?")
|
||||||
|
.get(transactionId) as any;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@ -214,23 +284,33 @@ export const purchaseDb = {
|
|||||||
phoneNumber: row.phoneNumber,
|
phoneNumber: row.phoneNumber,
|
||||||
txReceipt: row.txReceipt,
|
txReceipt: row.txReceipt,
|
||||||
purchaseDate: new Date(row.purchaseDate),
|
purchaseDate: new Date(row.purchaseDate),
|
||||||
|
approvalStatus: row.approvalStatus,
|
||||||
|
paymentMethod: row.paymentMethod,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
create(purchase: Omit<Purchase, 'id'>): Purchase {
|
create(purchase: Omit<Purchase, "id">): Purchase {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const result = db.prepare(`
|
const result = db
|
||||||
INSERT INTO purchases (albumId, transactionId, customerName, email, phoneNumber, txReceipt, purchaseDate)
|
.prepare(
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
`
|
||||||
`).run(
|
INSERT INTO purchases (albumId, transactionId, customerName, email, phoneNumber, txReceipt, purchaseDate, approvalStatus, paymentMethod)
|
||||||
purchase.albumId,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
purchase.transactionId,
|
`,
|
||||||
purchase.customerName || null,
|
)
|
||||||
purchase.email || null,
|
.run(
|
||||||
purchase.phoneNumber || null,
|
purchase.albumId,
|
||||||
purchase.txReceipt || null,
|
purchase.transactionId,
|
||||||
purchase.purchaseDate instanceof Date ? purchase.purchaseDate.getTime() : purchase.purchaseDate
|
purchase.customerName || null,
|
||||||
);
|
purchase.email || null,
|
||||||
|
purchase.phoneNumber || null,
|
||||||
|
purchase.txReceipt || null,
|
||||||
|
purchase.purchaseDate instanceof Date
|
||||||
|
? purchase.purchaseDate.getTime()
|
||||||
|
: purchase.purchaseDate,
|
||||||
|
purchase.approvalStatus || 'pending',
|
||||||
|
purchase.paymentMethod || 'card-to-card',
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: result.lastInsertRowid as number,
|
id: result.lastInsertRowid as number,
|
||||||
@ -240,6 +320,6 @@ export const purchaseDb = {
|
|||||||
|
|
||||||
delete(id: number): void {
|
delete(id: number): void {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
db.prepare('DELETE FROM purchases WHERE id = ?').run(id);
|
db.prepare("DELETE FROM purchases WHERE id = ?").run(id);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,6 +23,9 @@ export interface Album {
|
|||||||
bitrate: string; // e.g., "320kbps", "lossless"
|
bitrate: string; // e.g., "320kbps", "lossless"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PurchaseStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
export type PaymentMethod = 'ipg' | 'card-to-card';
|
||||||
|
|
||||||
export interface Purchase {
|
export interface Purchase {
|
||||||
id?: number;
|
id?: number;
|
||||||
albumId: string;
|
albumId: string;
|
||||||
@ -32,4 +35,6 @@ export interface Purchase {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
txReceipt?: string;
|
txReceipt?: string;
|
||||||
purchaseDate: Date | number;
|
purchaseDate: Date | number;
|
||||||
|
approvalStatus?: PurchaseStatus; // pending, approved, rejected
|
||||||
|
paymentMethod?: PaymentMethod; // ipg or card-to-card
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,14 +12,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.937.0",
|
"@aws-sdk/client-s3": "^3.937.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.937.0",
|
"@aws-sdk/s3-request-presigner": "^3.937.0",
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
"framer-motion": "^11.11.17",
|
"framer-motion": "^11.11.17",
|
||||||
"next": "^15.0.0",
|
"next": "^15.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-icons": "^5.3.0"
|
"react-icons": "^5.3.0",
|
||||||
|
"zarinpal-node-sdk": "^2.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/bun": "^1.3.5",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@ -22,6 +22,9 @@
|
|||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
],
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user