main: second iter

Signed-off-by: nfel <nfilsaraee@gmail.com>
This commit is contained in:
nfel 2025-12-27 22:41:36 +03:30
parent 8a7842e263
commit 9a7e627329
Signed by: nfel
GPG Key ID: DCC0BF3F92B0D45F
33 changed files with 4865 additions and 81 deletions

55
.dockerignore Normal file
View File

@ -0,0 +1,55 @@
# dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# next.js
.next/
out/
build/
dist/
# production
.vercel
.turbo
# misc
.DS_Store
*.pem
# debug
*.log
# local env files
.env*.local
.env
# git
.git
.gitignore
# IDE
.vscode
.idea
*.swp
*.swo
*~
# testing
coverage
.nyc_output
# docker
Dockerfile
docker-compose.yml
.dockerignore
# README and docs
README.md
# Database
data/
*.db
*.db-shm
*.db-wal

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Application
NODE_ENV=production
NEXT_PUBLIC_APP_URL=http://localhost:3000
# AWS S3 Configuration (for file uploads)
AWS_REGION=ir-thr-at1
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

6
.gitignore vendored
View File

@ -37,3 +37,9 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# database
/data/
*.db
*.db-shm
*.db-wal

58
Dockerfile Normal file
View File

@ -0,0 +1,58 @@
# Multi-stage build for Next.js application
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm ci
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set environment variables for build
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Build the application
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Change ownership to nextjs user
RUN chown -R nextjs:nodejs /app
# Switch to non-root user
USER nextjs
# Expose port 3000
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Start the application
CMD ["node", "server.js"]

184
app/admin/albums/page.tsx Normal file
View File

@ -0,0 +1,184 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { FaEdit, FaTrash, FaPlus } from 'react-icons/fa';
import { useAlbums } from '@/lib/AlbumsContext';
import { Album } from '@/lib/types';
import AdminLayout from '@/components/AdminLayout';
import AddAlbumModal from '@/components/AddAlbumModal';
import EditAlbumModal from '@/components/EditAlbumModal';
import DeleteConfirmationModal from '@/components/DeleteConfirmationModal';
export default function AdminAlbumsPage() {
const { albums, addAlbum, updateAlbum, deleteAlbum } = useAlbums();
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<Album | null>(null);
const handleAddAlbum = async (album: Album) => {
try {
await addAlbum(album);
setShowAddModal(false);
} catch (error) {
console.error('Error adding album:', error);
alert('Failed to add album');
}
};
const handleEditAlbum = async (albumId: string, album: Album) => {
try {
await updateAlbum(albumId, album);
setShowEditModal(false);
setSelectedAlbum(null);
} catch (error) {
console.error('Error updating album:', error);
alert('Failed to update album');
}
};
const handleDeleteAlbum = async () => {
if (selectedAlbum) {
try {
await deleteAlbum(selectedAlbum.id);
setShowDeleteModal(false);
setSelectedAlbum(null);
} catch (error) {
console.error('Error deleting album:', error);
alert('Failed to delete album');
}
}
};
const openEditModal = (album: Album) => {
setSelectedAlbum(album);
setShowEditModal(true);
};
const openDeleteModal = (album: Album) => {
setSelectedAlbum(album);
setShowDeleteModal(true);
};
return (
<AdminLayout>
<div className="p-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Album Management</h1>
<p className="text-gray-400">Manage your music catalog {albums.length} albums</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
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"
>
<FaPlus />
Add New Album
</motion.button>
</div>
{/* Albums Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{albums.map((album, index) => (
<motion.div
key={album.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="glass-effect rounded-xl p-6 border border-white/10"
>
<div className="flex gap-4">
{/* 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">
<span className="text-xs text-white/50 font-bold text-center px-2">
{album.title}
</span>
</div>
{/* Album Info */}
<div className="flex-1">
<h3 className="text-xl font-bold text-white mb-1">{album.title}</h3>
<p className="text-sm text-gray-400 mb-2">
{album.year} {album.genre}
</p>
<p className="text-sm text-gray-500 mb-3 line-clamp-2">
{album.description}
</p>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>{album.songs.length} tracks</span>
<span className="text-accent-orange font-bold">${album.price}</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => openEditModal(album)}
className="p-2 bg-accent-cyan/20 hover:bg-accent-cyan/30 rounded-lg text-accent-cyan transition-colors"
aria-label="Edit album"
>
<FaEdit />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => openDeleteModal(album)}
className="p-2 bg-red-500/20 hover:bg-red-500/30 rounded-lg text-red-400 transition-colors"
aria-label="Delete album"
>
<FaTrash />
</motion.button>
</div>
</div>
{/* Track List */}
<div className="mt-4 pt-4 border-t border-white/10">
<p className="text-sm font-semibold text-gray-400 mb-2">Tracks:</p>
<div className="space-y-1">
{album.songs.slice(0, 3).map((song, idx) => (
<div key={song.id} className="flex justify-between text-sm">
<span className="text-gray-300">
{idx + 1}. {song.title}
</span>
<span className="text-gray-500 font-mono">{song.duration}</span>
</div>
))}
{album.songs.length > 3 && (
<p className="text-xs text-gray-500">+{album.songs.length - 3} more tracks</p>
)}
</div>
</div>
</motion.div>
))}
</div>
{/* Modals */}
<AddAlbumModal show={showAddModal} onClose={() => setShowAddModal(false)} onAdd={handleAddAlbum} />
<EditAlbumModal
show={showEditModal}
album={selectedAlbum}
onClose={() => {
setShowEditModal(false);
setSelectedAlbum(null);
}}
onUpdate={handleEditAlbum}
/>
<DeleteConfirmationModal
show={showDeleteModal}
album={selectedAlbum}
onClose={() => {
setShowDeleteModal(false);
setSelectedAlbum(null);
}}
onConfirm={handleDeleteAlbum}
/>
</div>
</AdminLayout>
);
}

View File

@ -0,0 +1,154 @@
'use client';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { FaMusic, FaShoppingCart, FaDollarSign, FaUsers } from 'react-icons/fa';
import { useAlbums } from '@/lib/AlbumsContext';
import { Purchase } from '@/lib/types';
import AdminLayout from '@/components/AdminLayout';
export default function AdminDashboard() {
const { albums } = useAlbums();
const [purchases, setPurchases] = useState<Purchase[]>([]);
const [stats, setStats] = useState({
totalAlbums: 0,
totalPurchases: 0,
totalRevenue: 0,
recentPurchases: [] as Purchase[],
});
useEffect(() => {
// Load purchases from localStorage
const savedPurchases = localStorage.getItem('purchases');
if (savedPurchases) {
const parsedPurchases = JSON.parse(savedPurchases);
setPurchases(parsedPurchases);
// Calculate stats
const totalRevenue = parsedPurchases.reduce((total: number, purchase: Purchase) => {
const album = albums.find((a) => a.id === purchase.albumId);
return total + (album?.price || 0);
}, 0);
setStats({
totalAlbums: albums.length,
totalPurchases: parsedPurchases.length,
totalRevenue,
recentPurchases: parsedPurchases.slice(-5).reverse(),
});
} else {
setStats({
totalAlbums: albums.length,
totalPurchases: 0,
totalRevenue: 0,
recentPurchases: [],
});
}
}, []);
const statCards = [
{
icon: FaMusic,
label: 'Total Albums',
value: stats.totalAlbums,
color: 'from-accent-cyan to-accent-cyan/80',
iconBg: 'bg-accent-cyan/20',
},
{
icon: FaShoppingCart,
label: 'Total Purchases',
value: stats.totalPurchases,
color: 'from-accent-orange to-accent-orange/80',
iconBg: 'bg-accent-orange/20',
},
{
icon: FaDollarSign,
label: 'Total Revenue',
value: `$${stats.totalRevenue.toFixed(2)}`,
color: 'from-green-500 to-green-600',
iconBg: 'bg-green-500/20',
},
{
icon: FaUsers,
label: 'Active Users',
value: stats.totalPurchases > 0 ? Math.ceil(stats.totalPurchases / 2) : 0,
color: 'from-purple-500 to-purple-600',
iconBg: 'bg-purple-500/20',
},
];
return (
<AdminLayout>
<div className="p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Dashboard</h1>
<p className="text-gray-400">Overview of your music store</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat, index) => {
const Icon = stat.icon;
return (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="glass-effect rounded-xl p-6 border border-white/10"
>
<div className="flex items-center gap-4">
<div className={`p-3 ${stat.iconBg} rounded-lg`}>
<Icon className="text-2xl text-white" />
</div>
<div>
<p className="text-sm text-gray-400">{stat.label}</p>
<p className="text-2xl font-bold text-white">{stat.value}</p>
</div>
</div>
</motion.div>
);
})}
</div>
{/* Recent Purchases */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="glass-effect rounded-xl p-6 border border-white/10"
>
<h2 className="text-xl font-bold text-white mb-4">Recent Purchases</h2>
{stats.recentPurchases.length > 0 ? (
<div className="space-y-3">
{stats.recentPurchases.map((purchase) => {
const album = albums.find((a) => a.id === purchase.albumId);
return (
<div
key={purchase.transactionId}
className="flex items-center justify-between p-4 bg-white/5 rounded-lg"
>
<div>
<p className="text-white font-medium">{album?.title || 'Unknown Album'}</p>
<p className="text-sm text-gray-400">
{new Date(purchase.purchaseDate).toLocaleDateString()} at{' '}
{new Date(purchase.purchaseDate).toLocaleTimeString()}
</p>
</div>
<div className="text-right">
<p className="text-accent-orange font-bold">${album?.price || 0}</p>
<p className="text-xs text-gray-500 font-mono">{purchase.transactionId.slice(0, 12)}...</p>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-400 text-center py-8">No purchases yet</p>
)}
</motion.div>
</div>
</AdminLayout>
);
}

109
app/admin/page.tsx Normal file
View File

@ -0,0 +1,109 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { FaLock, FaUser } from 'react-icons/fa';
import { useAdmin } from '@/lib/AdminContext';
export default function AdminLoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAdmin();
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
const success = login(username, password);
if (success) {
router.push('/admin/dashboard');
} else {
setError('Invalid credentials. Try admin / admin123');
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-primary-900 via-primary-800 to-primary-900">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md"
>
<div className="glass-effect rounded-2xl p-8 border border-white/10">
{/* Header */}
<div className="text-center mb-8">
<div className="inline-block p-4 bg-accent-cyan/20 rounded-full mb-4">
<FaLock className="text-4xl text-accent-cyan" />
</div>
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan to-accent-orange mb-2">
Admin Panel
</h1>
<p className="text-gray-400">Sign in to manage your music store</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<FaUser className="inline mr-2" />
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
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"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<FaLock className="inline mr-2" />
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
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"
required
/>
</div>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm"
>
{error}
</motion.div>
)}
<button
type="submit"
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"
>
Sign In
</button>
</form>
{/* Demo Credentials */}
<div className="mt-6 p-4 bg-white/5 rounded-lg">
<p className="text-xs text-gray-400 text-center">
Demo Credentials:<br />
<span className="text-accent-cyan">Username: admin</span><br />
<span className="text-accent-cyan">Password: admin123</span>
</p>
</div>
</div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,162 @@
'use client';
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { FaDownload, FaSearch } from 'react-icons/fa';
import { useAlbums } from '@/lib/AlbumsContext';
import { Purchase } from '@/lib/types';
import AdminLayout from '@/components/AdminLayout';
export default function AdminPurchasesPage() {
const { albums } = useAlbums();
const [purchases, setPurchases] = useState<Purchase[]>([]);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const savedPurchases = localStorage.getItem('purchases');
if (savedPurchases) {
setPurchases(JSON.parse(savedPurchases).reverse());
}
}, []);
const filteredPurchases = purchases.filter((purchase) => {
const album = albums.find((a) => a.id === purchase.albumId);
return (
album?.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
purchase.transactionId.toLowerCase().includes(searchTerm.toLowerCase())
);
});
const totalRevenue = purchases.reduce((total, purchase) => {
const album = albums.find((a) => a.id === purchase.albumId);
return total + (album?.price || 0);
}, 0);
const exportToCSV = () => {
const headers = ['Date', 'Time', 'Transaction ID', 'Album', 'Price'];
const rows = purchases.map((purchase) => {
const album = albums.find((a) => a.id === purchase.albumId);
const date = new Date(purchase.purchaseDate);
return [
date.toLocaleDateString(),
date.toLocaleTimeString(),
purchase.transactionId,
album?.title || 'Unknown',
`$${album?.price || 0}`,
];
});
const csvContent = [headers, ...rows].map((row) => row.join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `purchases-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<AdminLayout>
<div className="p-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Purchase History</h1>
<p className="text-gray-400">
{purchases.length} total purchases ${totalRevenue.toFixed(2)} revenue
</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={exportToCSV}
className="px-6 py-3 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 flex items-center gap-2"
>
<FaDownload />
Export CSV
</motion.button>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative">
<FaSearch className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search by album name or transaction ID..."
className="w-full pl-12 pr-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"
/>
</div>
</div>
{/* Purchases Table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-effect rounded-xl border border-white/10 overflow-hidden"
>
{filteredPurchases.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/5 border-b border-white/10">
<tr>
<th className="text-left p-4 text-sm font-semibold text-gray-400">Date & Time</th>
<th className="text-left p-4 text-sm font-semibold text-gray-400">Transaction ID</th>
<th className="text-left p-4 text-sm font-semibold text-gray-400">Album</th>
<th className="text-left p-4 text-sm font-semibold text-gray-400">Genre</th>
<th className="text-right p-4 text-sm font-semibold text-gray-400">Price</th>
</tr>
</thead>
<tbody>
{filteredPurchases.map((purchase, index) => {
const album = albums.find((a) => a.id === purchase.albumId);
const date = new Date(purchase.purchaseDate);
return (
<motion.tr
key={purchase.transactionId}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="border-b border-white/5 hover:bg-white/5 transition-colors"
>
<td className="p-4 text-white">
<div className="text-sm">{date.toLocaleDateString()}</div>
<div className="text-xs text-gray-500">{date.toLocaleTimeString()}</div>
</td>
<td className="p-4">
<code className="text-xs text-accent-cyan bg-accent-cyan/10 px-2 py-1 rounded">
{purchase.transactionId}
</code>
</td>
<td className="p-4">
<div className="text-white font-medium">{album?.title || 'Unknown'}</div>
<div className="text-xs text-gray-500">{album?.songs.length} tracks</div>
</td>
<td className="p-4 text-gray-400 text-sm">{album?.genre}</td>
<td className="p-4 text-right">
<span className="text-accent-orange font-bold">${album?.price || 0}</span>
</td>
</motion.tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="p-12 text-center">
<p className="text-gray-400">
{searchTerm ? 'No purchases found matching your search' : 'No purchases yet'}
</p>
</div>
)}
</motion.div>
</div>
</AdminLayout>
);
}

View File

@ -4,9 +4,9 @@ import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { FaPlay, FaShoppingCart, FaArrowLeft, FaClock, FaMusic } from 'react-icons/fa'; import { FaPlay, FaShoppingCart, FaArrowLeft, FaClock, FaMusic } from 'react-icons/fa';
import { albums } from '@/lib/data';
import { Album, Purchase } from '@/lib/types'; import { Album, Purchase } from '@/lib/types';
import { useCart } from '@/lib/CartContext'; import { useCart } from '@/lib/CartContext';
import { useAlbums } from '@/lib/AlbumsContext';
import Header from '@/components/Header'; import Header from '@/components/Header';
import CartSidebar from '@/components/CartSidebar'; import CartSidebar from '@/components/CartSidebar';
import MusicPlayer from '@/components/MusicPlayer'; import MusicPlayer from '@/components/MusicPlayer';
@ -17,6 +17,7 @@ export default function AlbumDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const { addToCart, isInCart } = useCart(); const { addToCart, isInCart } = useCart();
const { albums } = useAlbums();
const [album, setAlbum] = useState<Album | null>(null); const [album, setAlbum] = useState<Album | null>(null);
const [purchasedAlbums, setPurchasedAlbums] = useState<string[]>([]); const [purchasedAlbums, setPurchasedAlbums] = useState<string[]>([]);
const [purchases, setPurchases] = useState<Purchase[]>([]); const [purchases, setPurchases] = useState<Purchase[]>([]);

View File

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { albumDb } from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const album = albumDb.getById(params.id);
if (!album) {
return NextResponse.json(
{ error: 'Album not found' },
{ status: 404 }
);
}
return NextResponse.json(album, { status: 200 });
} catch (error) {
console.error('Error fetching album:', error);
return NextResponse.json(
{ error: 'Failed to fetch album' },
{ status: 500 }
);
}
}

115
app/api/albums/route.ts Normal file
View File

@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from 'next/server';
import { albumDb } from '@/lib/db';
import { Album } from '@/lib/types';
// GET - Get all albums
export async function GET() {
try {
const albums = albumDb.getAll();
return NextResponse.json(albums, { status: 200 });
} catch (error) {
console.error('Error fetching albums:', error);
return NextResponse.json(
{ error: 'Failed to fetch albums' },
{ status: 500 }
);
}
}
// POST - Create new album
export async function POST(request: NextRequest) {
try {
const album: Album = await request.json();
// Validate required fields
if (!album.id || !album.title || !album.year || !album.genre || !album.price) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Check if album already exists
const existing = albumDb.getById(album.id);
if (existing) {
return NextResponse.json(
{ error: 'Album with this ID already exists' },
{ status: 409 }
);
}
albumDb.create(album);
return NextResponse.json(album, { status: 201 });
} catch (error) {
console.error('Error creating album:', error);
return NextResponse.json(
{ error: 'Failed to create album' },
{ status: 500 }
);
}
}
// PUT - Update album
export async function PUT(request: NextRequest) {
try {
const album: Album = await request.json();
if (!album.id) {
return NextResponse.json(
{ error: 'Album ID is required' },
{ status: 400 }
);
}
// Check if album exists
const existing = albumDb.getById(album.id);
if (!existing) {
return NextResponse.json(
{ error: 'Album not found' },
{ status: 404 }
);
}
albumDb.update(album.id, album);
return NextResponse.json(album, { status: 200 });
} catch (error) {
console.error('Error updating album:', error);
return NextResponse.json(
{ error: 'Failed to update album' },
{ status: 500 }
);
}
}
// DELETE - Delete album
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Album ID is required' },
{ status: 400 }
);
}
// Check if album exists
const existing = albumDb.getById(id);
if (!existing) {
return NextResponse.json(
{ error: 'Album not found' },
{ status: 404 }
);
}
albumDb.delete(id);
return NextResponse.json({ message: 'Album deleted successfully' }, { status: 200 });
} catch (error) {
console.error('Error deleting album:', error);
return NextResponse.json(
{ error: 'Failed to delete album' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { purchaseDb } from '@/lib/db';
import { Purchase } from '@/lib/types';
// GET - Get all purchases
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const albumId = searchParams.get('albumId');
let purchases;
if (albumId) {
purchases = purchaseDb.getByAlbumId(albumId);
} else {
purchases = purchaseDb.getAll();
}
return NextResponse.json(purchases, { status: 200 });
} catch (error) {
console.error('Error fetching purchases:', error);
return NextResponse.json(
{ error: 'Failed to fetch purchases' },
{ status: 500 }
);
}
}
// POST - Create new purchase
export async function POST(request: NextRequest) {
try {
const purchaseData: Omit<Purchase, 'id'> = await request.json();
// Validate required fields
if (!purchaseData.albumId || !purchaseData.transactionId) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
// Check if transaction ID already exists
const existing = purchaseDb.getByTransactionId(purchaseData.transactionId);
if (existing) {
return NextResponse.json(
{ error: 'Transaction ID already exists' },
{ status: 409 }
);
}
// Ensure purchaseDate is set
const purchase: Omit<Purchase, 'id'> = {
...purchaseData,
purchaseDate: purchaseData.purchaseDate || new Date(),
};
const created = purchaseDb.create(purchase);
return NextResponse.json(created, { status: 201 });
} catch (error) {
console.error('Error creating purchase:', error);
return NextResponse.json(
{ error: 'Failed to create purchase' },
{ status: 500 }
);
}
}
// DELETE - Delete purchase
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Purchase ID is required' },
{ status: 400 }
);
}
purchaseDb.delete(parseInt(id));
return NextResponse.json({ message: 'Purchase deleted successfully' }, { status: 200 });
} catch (error) {
console.error('Error deleting purchase:', error);
return NextResponse.json(
{ error: 'Failed to delete purchase' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { uploadFileToS3, generateFileKey } from '@/lib/s3';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const folder = formData.get('folder') as string || 'audio';
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Validate file type (audio only)
const allowedTypes = [
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/ogg',
'audio/flac',
'audio/aac',
'audio/m4a',
];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only audio files are allowed.' },
{ status: 400 }
);
}
// Validate file size (max 50MB for audio)
const maxSize = 50 * 1024 * 1024; // 50MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 50MB.' },
{ status: 400 }
);
}
// Generate unique key
const key = generateFileKey(folder, file.name);
// Upload to S3
const url = await uploadFileToS3({
file,
key,
contentType: file.type,
});
return NextResponse.json({ url, key }, { status: 200 });
} catch (error) {
console.error('Audio upload error:', error);
return NextResponse.json(
{ error: 'Failed to upload audio file' },
{ status: 500 }
);
}
}

50
app/api/upload/route.ts Normal file
View File

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { uploadFileToS3, generateFileKey } from '@/lib/s3';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const folder = formData.get('folder') as string || 'uploads';
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Validate file type (images only)
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only images are allowed.' },
{ status: 400 }
);
}
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 5MB.' },
{ status: 400 }
);
}
// Generate unique key
const key = generateFileKey(folder, file.name);
// Upload to S3
const url = await uploadFileToS3({
file,
key,
contentType: file.type,
});
return NextResponse.json({ url, key }, { status: 200 });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
);
}
}

View File

@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import { Inter } from 'next/font/google'; import { Inter } 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 { AlbumsProvider } from '@/lib/AlbumsContext';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
@ -19,7 +21,11 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body className={inter.className}> <body className={inter.className}>
<CartProvider>{children}</CartProvider> <AdminProvider>
<AlbumsProvider>
<CartProvider>{children}</CartProvider>
</AlbumsProvider>
</AdminProvider>
</body> </body>
</html> </html>
); );

View File

@ -0,0 +1,413 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaTimes, FaPlus, FaTrash, FaUpload, FaImage } from 'react-icons/fa';
import { Album, Song } from '@/lib/types';
interface AddAlbumModalProps {
show: boolean;
onClose: () => void;
onAdd: (album: Album) => void;
}
export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalProps) {
const [title, setTitle] = useState('');
const [year, setYear] = useState('');
const [genre, setGenre] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('');
const [coverImage, setCoverImage] = useState('');
const [coverImageFile, setCoverImageFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [songs, setSongs] = useState<Song[]>([{ id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
const [error, setError] = useState('');
const handleAddSong = () => {
setSongs([...songs, { id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
};
const handleRemoveSong = (index: number) => {
if (songs.length > 1) {
setSongs(songs.filter((_, i) => i !== index));
}
};
const handleSongChange = (index: number, field: 'title' | 'duration' | 'previewUrl' | 'fullUrl', value: string) => {
const updatedSongs = [...songs];
updatedSongs[index][field] = value;
setSongs(updatedSongs);
};
const handleCoverImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setCoverImageFile(file);
setIsUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', 'album-covers');
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
setCoverImage(data.url);
} catch (err) {
setError('Failed to upload cover image');
setCoverImageFile(null);
} finally {
setIsUploading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validation
if (!title.trim()) {
setError('Title is required');
return;
}
const yearNum = parseInt(year);
if (!year || yearNum < 1900 || yearNum > new Date().getFullYear() + 1) {
setError('Please enter a valid year');
return;
}
if (!genre.trim()) {
setError('Genre is required');
return;
}
if (!description.trim()) {
setError('Description is required');
return;
}
const priceNum = parseFloat(price);
if (!price || priceNum <= 0) {
setError('Please enter a valid price');
return;
}
// Validate songs
for (let i = 0; i < songs.length; i++) {
if (!songs[i].title.trim()) {
setError(`Song ${i + 1} title is required`);
return;
}
if (!songs[i].duration.trim()) {
setError(`Song ${i + 1} duration is required`);
return;
}
}
// Create album
const albumId = title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
const albumSongs: Song[] = songs.map((song, index) => ({
id: `${albumId}-${index + 1}`,
title: song.title,
duration: song.duration,
previewUrl: song.previewUrl || '/audio/preview-1.mp3',
fullUrl: song.fullUrl || '/audio/default-full.mp3',
}));
const newAlbum: Album = {
id: albumId,
title,
coverImage: coverImage || '/albums/default-cover.jpg',
year: yearNum,
genre,
description,
price: priceNum,
songs: albumSongs,
};
onAdd(newAlbum);
handleClose();
};
const handleClose = () => {
setTitle('');
setYear('');
setGenre('');
setDescription('');
setPrice('');
setCoverImage('');
setCoverImageFile(null);
setSongs([{ id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
setError('');
onClose();
};
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={handleClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass-effect rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-8 border-2 border-accent-cyan/30"
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan to-accent-orange">
Add New Album
</h2>
<button
onClick={handleClose}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
aria-label="Close"
>
<FaTimes className="text-xl text-gray-400" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Album Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter album title"
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"
required
/>
</div>
{/* Cover Image Upload */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Album Cover Image
</label>
<div className="flex items-center gap-4">
<label className="flex-1 cursor-pointer">
<div className="flex items-center justify-center gap-2 px-4 py-3 bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-colors">
{isUploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-accent-cyan border-t-transparent"></div>
<span className="text-gray-400 text-sm">Uploading...</span>
</>
) : coverImage ? (
<>
<FaImage className="text-accent-cyan" />
<span className="text-accent-cyan text-sm">Image Uploaded</span>
</>
) : (
<>
<FaUpload className="text-gray-400" />
<span className="text-gray-400 text-sm">Choose Image</span>
</>
)}
</div>
<input
type="file"
accept="image/*"
onChange={handleCoverImageChange}
className="hidden"
disabled={isUploading}
/>
</label>
{coverImage && (
<div className="w-16 h-16 rounded-lg overflow-hidden border border-white/10">
<img src={coverImage} alt="Cover preview" className="w-full h-full object-cover" />
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Upload an album cover image (max 5MB, JPG/PNG/WEBP)
</p>
</div>
{/* Year and Genre */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Year *
</label>
<input
type="number"
value={year}
onChange={(e) => setYear(e.target.value)}
placeholder="2024"
min="1900"
max={new Date().getFullYear() + 1}
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"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Genre *
</label>
<input
type="text"
value={genre}
onChange={(e) => setGenre(e.target.value)}
placeholder="Progressive Rock"
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"
required
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter album description"
rows={3}
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 resize-none"
required
/>
</div>
{/* Price */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Price ($) *
</label>
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="9.99"
min="0.01"
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"
required
/>
</div>
{/* Songs */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-300">
Songs *
</label>
<button
type="button"
onClick={handleAddSong}
className="px-3 py-1 bg-accent-cyan/20 hover:bg-accent-cyan/30 text-accent-cyan rounded-lg text-sm flex items-center gap-1 transition-colors"
>
<FaPlus className="text-xs" />
Add Song
</button>
</div>
<div className="space-y-3">
{songs.map((song, index) => (
<div key={index} className="p-4 bg-white/5 rounded-lg border border-white/10 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300">Song {index + 1}</span>
{songs.length > 1 && (
<button
type="button"
onClick={() => handleRemoveSong(index)}
className="p-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
aria-label="Remove song"
>
<FaTrash className="text-xs" />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={song.title}
onChange={(e) => handleSongChange(index, 'title', e.target.value)}
placeholder="Song title"
className="px-3 py-2 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 text-sm"
required
/>
<input
type="text"
value={song.duration}
onChange={(e) => handleSongChange(index, 'duration', e.target.value)}
placeholder="3:45"
className="px-3 py-2 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 text-sm"
required
/>
</div>
<input
type="url"
value={song.fullUrl}
onChange={(e) => handleSongChange(index, 'fullUrl', e.target.value)}
placeholder="Song URL (e.g., https://example.com/song.mp3)"
className="w-full px-3 py-2 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 text-sm"
/>
<input
type="url"
value={song.previewUrl}
onChange={(e) => handleSongChange(index, 'previewUrl', e.target.value)}
placeholder="Preview URL (optional - 30s clip)"
className="w-full px-3 py-2 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 text-sm"
/>
</div>
))}
</div>
</div>
{/* Error Message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm"
>
{error}
</motion.div>
)}
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={handleClose}
className="flex-1 py-3 bg-white/10 hover:bg-white/20 rounded-lg font-semibold text-white transition-all"
>
Cancel
</button>
<button
type="submit"
className="flex-1 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"
>
Add Album
</button>
</div>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,35 @@
'use client';
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAdmin } from '@/lib/AdminContext';
import AdminSidebar from './AdminSidebar';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAdmin();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isAuthenticated && pathname !== '/admin') {
router.push('/admin');
}
}, [isAuthenticated, router, pathname]);
if (!isAuthenticated && pathname !== '/admin') {
return null;
}
if (pathname === '/admin') {
return <>{children}</>;
}
return (
<div className="min-h-screen bg-gradient-to-br from-primary-900 via-primary-800 to-primary-900 flex">
<AdminSidebar />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { motion } from 'framer-motion';
import { FaHome, FaMusic, FaShoppingBag, FaSignOutAlt, FaChartLine } from 'react-icons/fa';
import { useAdmin } from '@/lib/AdminContext';
export default function AdminSidebar() {
const router = useRouter();
const pathname = usePathname();
const { logout } = useAdmin();
const handleLogout = () => {
logout();
router.push('/admin');
};
const menuItems = [
{ icon: FaChartLine, label: 'Dashboard', path: '/admin/dashboard' },
{ icon: FaMusic, label: 'Albums', path: '/admin/albums' },
{ icon: FaShoppingBag, label: 'Purchases', path: '/admin/purchases' },
{ icon: FaHome, label: 'Back to Site', path: '/' },
];
return (
<div className="w-64 bg-primary-900/50 backdrop-blur-sm border-r border-white/10 flex flex-col">
{/* Header */}
<div className="p-6 border-b border-white/10">
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan to-accent-orange">
Admin Panel
</h1>
<p className="text-sm text-gray-400 mt-1">Parsa Music Store</p>
</div>
{/* Menu */}
<nav className="flex-1 p-4 space-y-2">
{menuItems.map((item) => {
const isActive = pathname === item.path;
const Icon = item.icon;
return (
<motion.button
key={item.path}
whileHover={{ x: 4 }}
onClick={() => router.push(item.path)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all ${
isActive
? 'bg-accent-cyan/20 text-accent-cyan'
: 'text-gray-300 hover:bg-white/5'
}`}
>
<Icon className="text-xl" />
<span className="font-medium">{item.label}</span>
</motion.button>
);
})}
</nav>
{/* Logout */}
<div className="p-4 border-t border-white/10">
<motion.button
whileHover={{ x: 4 }}
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-red-400 hover:bg-red-500/10 transition-all"
>
<FaSignOutAlt className="text-xl" />
<span className="font-medium">Logout</span>
</motion.button>
</div>
</div>
);
}

View File

@ -3,7 +3,7 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import AlbumCard from './AlbumCard'; import AlbumCard from './AlbumCard';
import { Album } from '@/lib/types'; import { Album } from '@/lib/types';
import { albums } from '@/lib/data'; import { useAlbums } from '@/lib/AlbumsContext';
interface AlbumShowcaseProps { interface AlbumShowcaseProps {
purchasedAlbums: string[]; purchasedAlbums: string[];
@ -12,6 +12,7 @@ interface AlbumShowcaseProps {
} }
export default function AlbumShowcase({ purchasedAlbums, onPlay, onPurchase }: AlbumShowcaseProps) { export default function AlbumShowcase({ purchasedAlbums, onPlay, onPurchase }: AlbumShowcaseProps) {
const { albums } = useAlbums();
return ( return (
<section className="py-20 px-4 md:px-8" id="albums"> <section className="py-20 px-4 md:px-8" id="albums">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">

View File

@ -0,0 +1,95 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { FaExclamationTriangle, FaTimes } from 'react-icons/fa';
import { Album } from '@/lib/types';
interface DeleteConfirmationModalProps {
show: boolean;
album: Album | null;
onClose: () => void;
onConfirm: () => void;
}
export default function DeleteConfirmationModal({
show,
album,
onClose,
onConfirm,
}: DeleteConfirmationModalProps) {
if (!album) return null;
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass-effect rounded-2xl max-w-md w-full p-8 border-2 border-red-500/30"
>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-3 bg-red-500/20 rounded-full">
<FaExclamationTriangle className="text-3xl text-red-400" />
</div>
<div>
<h2 className="text-2xl font-bold text-white">Delete Album</h2>
<p className="text-sm text-gray-400">This action cannot be undone</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
aria-label="Close"
>
<FaTimes className="text-xl text-gray-400" />
</button>
</div>
{/* Content */}
<div className="mb-6">
<p className="text-gray-300 mb-4">
Are you sure you want to delete the following album?
</p>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-white font-semibold">{album.title}</p>
<p className="text-sm text-gray-400">
{album.year} {album.genre} {album.songs.length} tracks
</p>
</div>
<p className="text-red-400 text-sm mt-4">
Warning: This will permanently delete this album and all associated data.
</p>
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-3 bg-white/10 hover:bg-white/20 rounded-lg font-semibold text-white transition-all"
>
Cancel
</button>
<button
onClick={onConfirm}
className="flex-1 py-3 bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 rounded-lg font-semibold text-white transition-all"
>
Delete Album
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,422 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FaTimes, FaPlus, FaTrash, FaUpload, FaImage } from 'react-icons/fa';
import { Album, Song } from '@/lib/types';
interface EditAlbumModalProps {
show: boolean;
album: Album | null;
onClose: () => void;
onUpdate: (albumId: string, album: Album) => void;
}
export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditAlbumModalProps) {
const [title, setTitle] = useState('');
const [year, setYear] = useState('');
const [genre, setGenre] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('');
const [coverImage, setCoverImage] = useState('');
const [coverImageFile, setCoverImageFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [songs, setSongs] = useState<Song[]>([{ id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
const [error, setError] = useState('');
// Populate form when album changes
useEffect(() => {
if (album) {
setTitle(album.title);
setYear(album.year.toString());
setGenre(album.genre);
setDescription(album.description);
setPrice(album.price.toString());
setCoverImage(album.coverImage || '');
setSongs(album.songs.map((song) => ({ ...song })));
}
}, [album]);
const handleAddSong = () => {
setSongs([...songs, { id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]);
};
const handleRemoveSong = (index: number) => {
if (songs.length > 1) {
setSongs(songs.filter((_, i) => i !== index));
}
};
const handleSongChange = (index: number, field: 'title' | 'duration' | 'previewUrl' | 'fullUrl', value: string) => {
const updatedSongs = [...songs];
updatedSongs[index][field] = value;
setSongs(updatedSongs);
};
const handleCoverImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setCoverImageFile(file);
setIsUploading(true);
setError('');
try {
const formData = new FormData();
formData.append('file', file);
formData.append('folder', 'album-covers');
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
setCoverImage(data.url);
} catch (err) {
setError('Failed to upload cover image');
setCoverImageFile(null);
} finally {
setIsUploading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!album) return;
// Validation
if (!title.trim()) {
setError('Title is required');
return;
}
const yearNum = parseInt(year);
if (!year || yearNum < 1900 || yearNum > new Date().getFullYear() + 1) {
setError('Please enter a valid year');
return;
}
if (!genre.trim()) {
setError('Genre is required');
return;
}
if (!description.trim()) {
setError('Description is required');
return;
}
const priceNum = parseFloat(price);
if (!price || priceNum <= 0) {
setError('Please enter a valid price');
return;
}
// Validate songs
for (let i = 0; i < songs.length; i++) {
if (!songs[i].title.trim()) {
setError(`Song ${i + 1} title is required`);
return;
}
if (!songs[i].duration.trim()) {
setError(`Song ${i + 1} duration is required`);
return;
}
}
// Update album
const albumSongs: Song[] = songs.map((song, index) => ({
id: song.id || `${album.id}-${index + 1}`,
title: song.title,
duration: song.duration,
previewUrl: song.previewUrl || '/audio/preview-1.mp3',
fullUrl: song.fullUrl || '/audio/default-full.mp3',
}));
const updatedAlbum: Album = {
id: album.id,
title,
coverImage: coverImage || album.coverImage || '/albums/default-cover.jpg',
year: yearNum,
genre,
description,
price: priceNum,
songs: albumSongs,
};
onUpdate(album.id, updatedAlbum);
handleClose();
};
const handleClose = () => {
setError('');
onClose();
};
if (!album) return null;
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={handleClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="glass-effect rounded-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-8 border-2 border-accent-orange/30"
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-orange to-accent-cyan">
Edit Album
</h2>
<button
onClick={handleClose}
className="p-2 hover:bg-white/10 rounded-full transition-colors"
aria-label="Close"
>
<FaTimes className="text-xl text-gray-400" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Album Title *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter album title"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
{/* Cover Image Upload */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Album Cover Image
</label>
<div className="flex items-center gap-4">
<label className="flex-1 cursor-pointer">
<div className="flex items-center justify-center gap-2 px-4 py-3 bg-white/5 border border-white/10 rounded-lg hover:bg-white/10 transition-colors">
{isUploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-accent-orange border-t-transparent"></div>
<span className="text-gray-400 text-sm">Uploading...</span>
</>
) : coverImage ? (
<>
<FaImage className="text-accent-orange" />
<span className="text-accent-orange text-sm">Change Image</span>
</>
) : (
<>
<FaUpload className="text-gray-400" />
<span className="text-gray-400 text-sm">Choose Image</span>
</>
)}
</div>
<input
type="file"
accept="image/*"
onChange={handleCoverImageChange}
className="hidden"
disabled={isUploading}
/>
</label>
{coverImage && (
<div className="w-16 h-16 rounded-lg overflow-hidden border border-white/10">
<img src={coverImage} alt="Cover preview" className="w-full h-full object-cover" />
</div>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
Upload an album cover image (max 5MB, JPG/PNG/WEBP)
</p>
</div>
{/* Year and Genre */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Year *
</label>
<input
type="number"
value={year}
onChange={(e) => setYear(e.target.value)}
placeholder="2024"
min="1900"
max={new Date().getFullYear() + 1}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Genre *
</label>
<input
type="text"
value={genre}
onChange={(e) => setGenre(e.target.value)}
placeholder="Progressive Rock"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Description *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter album description"
rows={3}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 resize-none"
required
/>
</div>
{/* Price */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Price ($) *
</label>
<input
type="number"
step="0.01"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="9.99"
min="0.01"
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500"
required
/>
</div>
{/* Songs */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-300">
Songs *
</label>
<button
type="button"
onClick={handleAddSong}
className="px-3 py-1 bg-accent-orange/20 hover:bg-accent-orange/30 text-accent-orange rounded-lg text-sm flex items-center gap-1 transition-colors"
>
<FaPlus className="text-xs" />
Add Song
</button>
</div>
<div className="space-y-3">
{songs.map((song, index) => (
<div key={index} className="p-4 bg-white/5 rounded-lg border border-white/10 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300">Song {index + 1}</span>
{songs.length > 1 && (
<button
type="button"
onClick={() => handleRemoveSong(index)}
className="p-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
aria-label="Remove song"
>
<FaTrash className="text-xs" />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={song.title}
onChange={(e) => handleSongChange(index, 'title', e.target.value)}
placeholder="Song title"
className="px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm"
required
/>
<input
type="text"
value={song.duration}
onChange={(e) => handleSongChange(index, 'duration', e.target.value)}
placeholder="3:45"
className="px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm"
required
/>
</div>
<input
type="url"
value={song.fullUrl}
onChange={(e) => handleSongChange(index, 'fullUrl', e.target.value)}
placeholder="Song URL (e.g., https://example.com/song.mp3)"
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm"
/>
<input
type="url"
value={song.previewUrl}
onChange={(e) => handleSongChange(index, 'previewUrl', e.target.value)}
placeholder="Preview URL (optional - 30s clip)"
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm"
/>
</div>
))}
</div>
</div>
{/* Error Message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm"
>
{error}
</motion.div>
)}
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={handleClose}
className="flex-1 py-3 bg-white/10 hover:bg-white/20 rounded-lg font-semibold text-white transition-all"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-3 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"
>
Update Album
</button>
</div>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -32,6 +32,7 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
console.log(audioRef.current)
if (!audio) return; if (!audio) return;
const updateTime = () => setCurrentTime(audio.currentTime); const updateTime = () => setCurrentTime(audio.currentTime);
@ -123,9 +124,9 @@ 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-4 pointer-events-none" className="fixed inset-0 z-50 flex items-center justify-center p-6 md:p-12 pointer-events-none"
> >
<div className="w-full max-w-md pointer-events-auto relative"> <div className="w-full max-w-sm pointer-events-auto relative">
{/* Close Button - Top Right */} {/* Close Button - Top Right */}
<motion.button <motion.button
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}

View File

@ -14,8 +14,9 @@ interface PaymentModalProps {
export default function PaymentModal({ album, onClose, onSuccess }: PaymentModalProps) { export default function PaymentModal({ album, onClose, onSuccess }: PaymentModalProps) {
const [cardNumber, setCardNumber] = useState(''); const [cardNumber, setCardNumber] = useState('');
const [cardName, setCardName] = useState(''); const [cardName, setCardName] = useState('');
const [expiry, setExpiry] = useState(''); const [phoneNumber, setPhoneNumber] = useState('');
const [cvv, setCvv] = useState(''); const [email, setEmail] = useState('');
const [txReceipt, setTxReceipt] = useState('');
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -25,12 +26,11 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
return groups ? groups.join(' ') : numbers; return groups ? groups.join(' ') : numbers;
}; };
const formatExpiry = (value: string) => { const formatPhoneNumber = (value: string) => {
const numbers = value.replace(/\D/g, ''); const numbers = value.replace(/\D/g, '');
if (numbers.length >= 2) { if (numbers.length <= 3) return numbers;
return numbers.slice(0, 2) + '/' + numbers.slice(2, 4); 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)}`;
return numbers;
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@ -44,32 +44,60 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
} }
if (!cardName.trim()) { if (!cardName.trim()) {
setError('Card name is required'); setError('Name is required');
return; return;
} }
if (expiry.length !== 5) { if (phoneNumber.replace(/\D/g, '').length < 10) {
setError('Invalid expiry date'); setError('Invalid phone number');
return; return;
} }
if (cvv.length !== 3 && cvv.length !== 4) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
setError('Invalid CVV'); if (!emailRegex.test(email)) {
setError('Invalid email address');
return; return;
} }
if (!txReceipt.trim()) {
setError('Transaction receipt is required');
return;
}
if (!album) return;
setProcessing(true); setProcessing(true);
// Simulate payment processing try {
setTimeout(() => { // Use the provided transaction receipt as the ID
// Generate a mock transaction ID const transactionId = txReceipt.trim() || 'TXN-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9).toUpperCase();
const transactionId = 'TXN-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9).toUpperCase();
// Create purchase via API
const response = await fetch('/api/purchases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
albumId: album.id,
transactionId,
customerName: cardName.trim(),
email: email.trim(),
phoneNumber: phoneNumber,
txReceipt: txReceipt.trim(),
purchaseDate: new Date().getTime(),
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to process purchase');
}
setProcessing(false); setProcessing(false);
if (album) { onSuccess(album.id, transactionId);
onSuccess(album.id, transactionId); } catch (err: any) {
} setProcessing(false);
}, 2000); setError(err.message || 'Failed to process payment');
}
}; };
if (!album) return null; if (!album) return null;
@ -136,46 +164,64 @@ export default function PaymentModal({ album, onClose, onSuccess }: PaymentModal
{/* Card Name */} {/* Card Name */}
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">
Name on Card Full Name
</label> </label>
<input <input
type="text" type="text"
value={cardName} value={cardName}
onChange={(e) => setCardName(e.target.value.toUpperCase())} 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-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"
required required
/> />
</div> </div>
{/* Expiry and CVV */} {/* Phone Number */}
<div className="grid grid-cols-2 gap-4"> <div>
<div> <label className="block text-sm font-medium text-gray-300 mb-2">
<label className="block text-sm font-medium text-gray-300 mb-2"> Phone Number
Expiry Date </label>
</label> <input
<input type="tel"
type="text" value={phoneNumber}
value={expiry} onChange={(e) => {
onChange={(e) => setExpiry(formatExpiry(e.target.value))} const formatted = formatPhoneNumber(e.target.value);
placeholder="MM/YY" setPhoneNumber(formatted);
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" }}
required placeholder="(123) 456-7890"
/> 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"
</div> required
<div> />
<label className="block text-sm font-medium text-gray-300 mb-2"> </div>
CVV
</label> {/* Email */}
<input <div>
type="text" <label className="block text-sm font-medium text-gray-300 mb-2">
value={cvv} Email Address
onChange={(e) => setCvv(e.target.value.replace(/\D/g, '').slice(0, 4))} </label>
placeholder="123" <input
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" type="email"
required value={email}
/> onChange={(e) => setEmail(e.target.value)}
</div> 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"
required
/>
</div>
{/* Transaction Receipt */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Transaction Receipt / ID
</label>
<input
type="text"
value={txReceipt}
onChange={(e) => setTxReceipt(e.target.value)}
placeholder="TXN-123456789"
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"
required
/>
</div> </div>
{/* Error Message */} {/* Error Message */}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { FaCheckCircle, FaTimes, FaDownload } from 'react-icons/fa'; import { FaClock, FaTimes, FaDownload } from 'react-icons/fa';
import { Album, Purchase } from '@/lib/types'; import { Album, Purchase } from '@/lib/types';
interface PurchaseSuccessModalProps { interface PurchaseSuccessModalProps {
@ -20,9 +20,10 @@ export default function PurchaseSuccessModal({ show, album, purchase, onClose }:
PURCHASE RECEIPT PURCHASE RECEIPT
================ ================
Status: PENDING APPROVAL
Album: ${album.title} Album: ${album.title}
Artist: Parsa Artist: Parsa
Price: $${album.price} Amount: $${album.price}
Transaction ID: ${purchase.transactionId} Transaction ID: ${purchase.transactionId}
Date: ${new Date(purchase.purchaseDate).toLocaleString()} Date: ${new Date(purchase.purchaseDate).toLocaleString()}
@ -30,7 +31,8 @@ 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!
You now have full access to this album. Your order is pending admin approval.
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' });
@ -64,14 +66,14 @@ You now have full access to this album.
{/* 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-green-500/20 rounded-full"> <div className="p-3 bg-accent-orange/20 rounded-full">
<FaCheckCircle className="text-3xl text-green-400" /> <FaClock className="text-3xl text-accent-orange" />
</div> </div>
<div> <div>
<h2 className="text-2xl font-bold text-white"> <h2 className="text-2xl font-bold text-white">
Purchase Successful! Purchase Pending Approval
</h2> </h2>
<p className="text-sm text-gray-400">Thank you for your purchase</p> <p className="text-sm text-gray-400">Please wait for admin confirmation</p>
</div> </div>
</div> </div>
<button <button
@ -99,7 +101,7 @@ You now have full access to this album.
<span className="text-white font-semibold">{album.songs.length} songs</span> <span className="text-white 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 border-white/10 pt-3 flex justify-between">
<span className="text-gray-400">Total Paid</span> <span className="text-gray-400">Amount</span>
<span className="text-accent-orange font-bold text-xl">${album.price}</span> <span className="text-accent-orange font-bold text-xl">${album.price}</span>
</div> </div>
</div> </div>
@ -129,13 +131,13 @@ You now have full access to this album.
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-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"
> >
Start Listening 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-gray-500 text-center mt-6">
You now have unlimited access to all tracks in this album Your purchase will be reviewed by an admin. You will receive access after approval.
</p> </p>
</motion.div> </motion.div>
</motion.div> </motion.div>

34
docker-compose.yml Normal file
View File

@ -0,0 +1,34 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: parsa-music-shop
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_APP_URL=http://localhost:3000
# AWS S3 Configuration (optional)
- AWS_REGION=${AWS_REGION:-ir-thr-at1}
- AWS_S3_ENDPOINT=${AWS_S3_ENDPOINT}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_S3_BUCKET=${AWS_S3_BUCKET}
volumes:
- ./data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- parsa-network
networks:
parsa-network:
driver: bridge

54
lib/AdminContext.tsx Normal file
View File

@ -0,0 +1,54 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface AdminContextType {
isAuthenticated: boolean;
login: (username: string, password: string) => boolean;
logout: () => void;
}
const AdminContext = createContext<AdminContextType | undefined>(undefined);
// Demo credentials - In production, use proper authentication
const ADMIN_USERNAME = 'admin';
const ADMIN_PASSWORD = 'admin123';
export function AdminProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
const authStatus = localStorage.getItem('adminAuth');
if (authStatus === 'true') {
setIsAuthenticated(true);
}
}, []);
const login = (username: string, password: string): boolean => {
if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
setIsAuthenticated(true);
localStorage.setItem('adminAuth', 'true');
return true;
}
return false;
};
const logout = () => {
setIsAuthenticated(false);
localStorage.removeItem('adminAuth');
};
return (
<AdminContext.Provider value={{ isAuthenticated, login, logout }}>
{children}
</AdminContext.Provider>
);
}
export function useAdmin() {
const context = useContext(AdminContext);
if (context === undefined) {
throw new Error('useAdmin must be used within an AdminProvider');
}
return context;
}

116
lib/AlbumsContext.tsx Normal file
View File

@ -0,0 +1,116 @@
'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Album } from './types';
interface AlbumsContextType {
albums: Album[];
loading: boolean;
addAlbum: (album: Album) => Promise<void>;
updateAlbum: (albumId: string, updatedAlbum: Album) => Promise<void>;
deleteAlbum: (albumId: string) => Promise<void>;
getAlbumById: (albumId: string) => Album | undefined;
refreshAlbums: () => Promise<void>;
}
const AlbumsContext = createContext<AlbumsContextType | undefined>(undefined);
export function AlbumsProvider({ children }: { children: ReactNode }) {
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
// Fetch albums from API
const fetchAlbums = async () => {
try {
setLoading(true);
const response = await fetch('/api/albums');
if (!response.ok) {
throw new Error('Failed to fetch albums');
}
const data = await response.json();
setAlbums(data);
} catch (error) {
console.error('Error fetching albums:', error);
} finally {
setLoading(false);
}
};
// Load albums on mount
useEffect(() => {
fetchAlbums();
}, []);
const addAlbum = async (album: Album) => {
try {
const response = await fetch('/api/albums', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(album),
});
if (!response.ok) {
throw new Error('Failed to add album');
}
await fetchAlbums(); // Refresh the list
} catch (error) {
console.error('Error adding album:', error);
throw error;
}
};
const updateAlbum = async (albumId: string, updatedAlbum: Album) => {
try {
const response = await fetch('/api/albums', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedAlbum),
});
if (!response.ok) {
throw new Error('Failed to update album');
}
await fetchAlbums(); // Refresh the list
} catch (error) {
console.error('Error updating album:', error);
throw error;
}
};
const deleteAlbum = async (albumId: string) => {
try {
const response = await fetch(`/api/albums?id=${albumId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete album');
}
await fetchAlbums(); // Refresh the list
} catch (error) {
console.error('Error deleting album:', error);
throw error;
}
};
const getAlbumById = (albumId: string) => {
return albums.find((album) => album.id === albumId);
};
return (
<AlbumsContext.Provider value={{ albums, loading, addAlbum, updateAlbum, deleteAlbum, getAlbumById, refreshAlbums: fetchAlbums }}>
{children}
</AlbumsContext.Provider>
);
}
export function useAlbums() {
const context = useContext(AlbumsContext);
if (context === undefined) {
throw new Error('useAlbums must be used within an AlbumsProvider');
}
return context;
}

226
lib/db.ts Normal file
View File

@ -0,0 +1,226 @@
import Database from 'better-sqlite3';
import path from 'path';
import { albums as initialAlbums } from './data';
import { Album, Purchase } from './types';
// Database path
const dbPath = path.join(process.cwd(), 'data', 'parsa.db');
// Initialize database
let db: Database.Database;
export function getDatabase(): Database.Database {
if (!db) {
// Create data directory if it doesn't exist
const fs = require('fs');
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
initializeDatabase();
}
return db;
}
function initializeDatabase() {
// Create albums table
db.exec(`
CREATE TABLE IF NOT EXISTS albums (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
coverImage TEXT NOT NULL,
year INTEGER NOT NULL,
genre TEXT NOT NULL,
description TEXT NOT NULL,
price REAL NOT NULL,
songs TEXT NOT NULL,
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
)
`);
// Create purchases table
db.exec(`
CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
albumId TEXT NOT NULL,
transactionId TEXT NOT NULL UNIQUE,
customerName TEXT,
email TEXT,
phoneNumber TEXT,
txReceipt TEXT,
purchaseDate INTEGER NOT NULL,
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (albumId) REFERENCES albums(id) ON DELETE CASCADE
)
`);
// Create indexes
db.exec(`
CREATE INDEX IF NOT EXISTS idx_purchases_albumId ON purchases(albumId);
CREATE INDEX IF NOT EXISTS idx_purchases_transactionId ON purchases(transactionId);
`);
// Seed initial data if albums table is empty
const count = db.prepare('SELECT COUNT(*) as count FROM albums').get() as { count: number };
if (count.count === 0) {
seedInitialData();
}
}
function seedInitialData() {
const insert = db.prepare(`
INSERT INTO albums (id, title, coverImage, year, genre, description, price, songs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertMany = db.transaction((albums: Album[]) => {
for (const album of albums) {
insert.run(
album.id,
album.title,
album.coverImage,
album.year,
album.genre,
album.description,
album.price,
JSON.stringify(album.songs)
);
}
});
insertMany(initialAlbums);
}
// Album operations
export const albumDb = {
getAll(): Album[] {
const rows = db.prepare('SELECT * FROM albums ORDER BY year DESC').all();
return rows.map((row: any) => ({
...row,
songs: JSON.parse(row.songs),
}));
},
getById(id: string): Album | null {
const row = db.prepare('SELECT * FROM albums WHERE id = ?').get(id) as any;
if (!row) return null;
return {
...row,
songs: JSON.parse(row.songs),
};
},
create(album: Album): void {
db.prepare(`
INSERT INTO albums (id, title, coverImage, year, genre, description, price, songs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
album.id,
album.title,
album.coverImage,
album.year,
album.genre,
album.description,
album.price,
JSON.stringify(album.songs)
);
},
update(id: string, album: Album): void {
db.prepare(`
UPDATE albums
SET title = ?, coverImage = ?, year = ?, genre = ?, description = ?, price = ?, songs = ?, updatedAt = strftime('%s', 'now')
WHERE id = ?
`).run(
album.title,
album.coverImage,
album.year,
album.genre,
album.description,
album.price,
JSON.stringify(album.songs),
id
);
},
delete(id: string): void {
db.prepare('DELETE FROM albums WHERE id = ?').run(id);
},
};
// Purchase operations
export const purchaseDb = {
getAll(): Purchase[] {
const rows = db.prepare('SELECT * FROM purchases ORDER BY purchaseDate DESC').all();
return rows.map((row: any) => ({
id: row.id,
albumId: row.albumId,
transactionId: row.transactionId,
customerName: row.customerName,
email: row.email,
phoneNumber: row.phoneNumber,
txReceipt: row.txReceipt,
purchaseDate: new Date(row.purchaseDate),
}));
},
getByAlbumId(albumId: string): Purchase[] {
const rows = db.prepare('SELECT * FROM purchases WHERE albumId = ? ORDER BY purchaseDate DESC').all(albumId);
return rows.map((row: any) => ({
id: row.id,
albumId: row.albumId,
transactionId: row.transactionId,
customerName: row.customerName,
email: row.email,
phoneNumber: row.phoneNumber,
txReceipt: row.txReceipt,
purchaseDate: new Date(row.purchaseDate),
}));
},
getByTransactionId(transactionId: string): Purchase | null {
const row = db.prepare('SELECT * FROM purchases WHERE transactionId = ?').get(transactionId) as any;
if (!row) return null;
return {
id: row.id,
albumId: row.albumId,
transactionId: row.transactionId,
customerName: row.customerName,
email: row.email,
phoneNumber: row.phoneNumber,
txReceipt: row.txReceipt,
purchaseDate: new Date(row.purchaseDate),
};
},
create(purchase: Omit<Purchase, 'id'>): Purchase {
const result = db.prepare(`
INSERT INTO purchases (albumId, transactionId, customerName, email, phoneNumber, txReceipt, purchaseDate)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
purchase.albumId,
purchase.transactionId,
purchase.customerName || null,
purchase.email || null,
purchase.phoneNumber || null,
purchase.txReceipt || null,
purchase.purchaseDate instanceof Date ? purchase.purchaseDate.getTime() : purchase.purchaseDate
);
return {
id: result.lastInsertRowid as number,
...purchase,
};
},
delete(id: number): void {
db.prepare('DELETE FROM purchases WHERE id = ?').run(id);
},
};
// Initialize database on import
getDatabase();

80
lib/s3.ts Normal file
View File

@ -0,0 +1,80 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Initialize S3 client
const s3Client = new S3Client({
region: process.env.AWS_REGION || 'ir-thr-at1',
endpoint: process.env.AWS_S3_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
},
});
const BUCKET_NAME = process.env.AWS_S3_BUCKET || '';
export interface UploadFileParams {
file: File;
key: string;
contentType?: string;
}
/**
* Upload a file to S3
*/
export async function uploadFileToS3(params: UploadFileParams): Promise<string> {
const { file, key, contentType } = params;
// Convert File to Buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
Body: buffer,
ACL: "public-read",
ContentType: contentType || file.type,
});
await s3Client.send(command);
// Return the public URL
return `https://${BUCKET_NAME}.s3.ir-thr-at1.arvanstorage.ir/${key}`;
}
/**
* Delete a file from S3
*/
export async function deleteFileFromS3(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
});
await s3Client.send(command);
}
/**
* Generate a presigned URL for uploading
*/
export async function getPresignedUploadUrl(key: string, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); // 1 hour
return url;
}
/**
* Generate a unique key for file uploads
*/
export function generateFileKey(folder: string, filename: string): string {
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const sanitizedFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
return `${folder}/${timestamp}-${randomString}-${sanitizedFilename}`;
}

View File

@ -18,7 +18,12 @@ export interface Album {
} }
export interface Purchase { export interface Purchase {
id?: number;
albumId: string; albumId: string;
transactionId: string; transactionId: string;
purchaseDate: Date; customerName?: string;
email?: string;
phoneNumber?: string;
txReceipt?: string;
purchaseDate: Date | number;
} }

View File

@ -1,7 +1,15 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
}; };
export default nextConfig; export default nextConfig;

2101
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,21 +9,25 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/s3-request-presigner": "^3.937.0",
"better-sqlite3": "^12.4.5",
"framer-motion": "^11.11.17",
"next": "^15.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"next": "^15.0.0",
"framer-motion": "^11.11.17",
"react-icons": "^5.3.0" "react-icons": "^5.3.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"postcss": "^8",
"autoprefixer": "^10", "autoprefixer": "^10",
"tailwindcss": "^3.4.1",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "15.0.0" "eslint-config-next": "15.0.0",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
} }
} }