main: added inital version of music shop
Signed-off-by: nfel <nfilsaraee@gmail.com>
This commit is contained in:
commit
8a7842e263
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
225
README.md
Normal file
225
README.md
Normal file
@ -0,0 +1,225 @@
|
||||
# Parsa - Progressive Rock Songwriter Website
|
||||
|
||||
A modern, interactive website showcasing a progressive rock composer and producer's work. Built with Next.js, TypeScript, and Tailwind CSS.
|
||||
|
||||
## Features
|
||||
|
||||
- **Artist Biography**: Beautiful introduction page with social media links
|
||||
- **Album Showcase**: Grid display of albums with hover effects and multiple actions
|
||||
- Play preview button
|
||||
- View album details button
|
||||
- Add to cart button
|
||||
- Direct purchase button
|
||||
- **Album Detail Page**: Dedicated page for each album featuring:
|
||||
- Full track list with durations
|
||||
- Album information and description
|
||||
- Total album duration and track count
|
||||
- Play, purchase, and add to cart actions
|
||||
- Dynamic routing (/album/[id])
|
||||
- **Shopping Cart System**:
|
||||
- Add multiple albums to cart
|
||||
- Persistent cart stored in localStorage
|
||||
- Cart sidebar with smooth animations
|
||||
- Item count badge on header cart icon
|
||||
- Remove items from cart
|
||||
- Checkout from cart
|
||||
- **Music Player**: Interactive player with:
|
||||
- 30-second preview mode for non-purchased albums
|
||||
- Full playback for purchased albums
|
||||
- Track list navigation
|
||||
- Play/pause controls
|
||||
- Progress bar (seekable for purchased albums)
|
||||
- **Payment System**:
|
||||
- Card payment form with validation
|
||||
- Transaction receipt generation
|
||||
- Purchase history stored in localStorage
|
||||
- Receipt download functionality
|
||||
- **Sample Album**: Muse - Showbiz album included with all 12 tracks:
|
||||
- Sunburn, Muscle Museum, Fillip, Falling Down, Cave, Showbiz
|
||||
- Unintended, Uno, Sober, Escape, Overdue, Hate This & I'll Love You
|
||||
- **Responsive Design**: Works on all screen sizes
|
||||
- **Bluish Theme**: Custom color palette with cyan and orange accents
|
||||
- **Smooth Animations**: Using Framer Motion for polished interactions
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Open [http://localhost:3000](http://localhost:3000) in your browser
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
parsa/
|
||||
├── app/
|
||||
│ ├── album/
|
||||
│ │ └── [id]/
|
||||
│ │ └── page.tsx # Album detail page (dynamic route)
|
||||
│ ├── layout.tsx # Root layout with CartProvider
|
||||
│ ├── page.tsx # Main page with state management
|
||||
│ └── globals.css # Global styles and Tailwind
|
||||
├── components/
|
||||
│ ├── Header.tsx # Navigation header with cart icon
|
||||
│ ├── Biography.tsx # Artist bio section
|
||||
│ ├── AlbumCard.tsx # Album card with navigation & cart actions
|
||||
│ ├── AlbumShowcase.tsx # Album grid display
|
||||
│ ├── MusicPlayer.tsx # Music player with controls
|
||||
│ ├── PaymentModal.tsx # Payment form modal
|
||||
│ ├── PurchaseSuccessModal.tsx # Receipt display
|
||||
│ └── CartSidebar.tsx # Shopping cart sidebar
|
||||
├── lib/
|
||||
│ ├── types.ts # TypeScript interfaces
|
||||
│ ├── data.ts # Album and artist data (includes Showbiz)
|
||||
│ └── CartContext.tsx # Cart state management context
|
||||
└── public/
|
||||
└── audio/ # Audio files directory
|
||||
```
|
||||
|
||||
## Adding Audio Files
|
||||
|
||||
1. Place your audio files in `/public/audio/`
|
||||
2. Preview files should be 30-second clips
|
||||
3. Update the URLs in `lib/data.ts` to match your file names:
|
||||
|
||||
```typescript
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/echoes-1-full.mp3"
|
||||
```
|
||||
|
||||
## Adding Album Cover Images
|
||||
|
||||
1. Place album cover images in `/public/albums/`
|
||||
2. Update the `coverImage` path in `lib/data.ts`:
|
||||
|
||||
```typescript
|
||||
coverImage: "/albums/echoes-of-time.jpg"
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
### Color Theme
|
||||
|
||||
Edit `tailwind.config.ts` to customize colors:
|
||||
|
||||
```typescript
|
||||
colors: {
|
||||
primary: { /* bluish palette */ },
|
||||
accent: {
|
||||
orange: '#ff6b35',
|
||||
cyan: '#00d9ff',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Artist Information
|
||||
|
||||
Edit `lib/data.ts`:
|
||||
|
||||
```typescript
|
||||
export const artistBio = {
|
||||
name: "Your Name",
|
||||
title: "Your Title",
|
||||
bio: "Your bio...",
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Albums
|
||||
|
||||
Add or modify albums in `lib/data.ts`:
|
||||
|
||||
```typescript
|
||||
export const albums: Album[] = [
|
||||
{
|
||||
id: "album-id",
|
||||
title: "Album Title",
|
||||
// ...
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Features in Detail
|
||||
|
||||
### Shopping Cart
|
||||
|
||||
- **Add to Cart**: Click the shopping cart icon on any album card to add it to your cart
|
||||
- **Cart Sidebar**: Click the cart icon in the header to view your cart
|
||||
- **Persistent Storage**: Cart items are saved in localStorage and persist across sessions
|
||||
- **Checkout**: Purchase all items in cart with a single checkout process
|
||||
- **Item Count Badge**: See the number of items in your cart at a glance
|
||||
|
||||
### Album Detail Pages
|
||||
|
||||
- **Direct Links**: Click the info icon on album cards or navigate to `/album/[album-id]`
|
||||
- **Full Track List**: View all songs with their durations
|
||||
- **Album Stats**: See total duration and track count
|
||||
- **Multiple Actions**: Play preview, purchase directly, or add to cart
|
||||
- **Back Navigation**: Easy navigation back to the main albums page
|
||||
|
||||
### Preview vs Full Access
|
||||
|
||||
- **Preview Mode**: Users can listen to 30-second previews of each track
|
||||
- **Full Access**: After purchase, users get unlimited playback with seeking controls
|
||||
|
||||
### Purchase System
|
||||
|
||||
- **Multiple Purchase Options**: Buy directly from album card, detail page, or cart
|
||||
- Mock payment system (no real transactions)
|
||||
- Stores purchase data in browser localStorage
|
||||
- Generates downloadable transaction receipts
|
||||
- Automatically unlocks full album playback
|
||||
- Purchased albums are marked with "Owned" badge
|
||||
|
||||
### Responsive Design
|
||||
|
||||
- Mobile-first approach
|
||||
- Adapts to tablet and desktop screens
|
||||
- Touch-friendly controls
|
||||
|
||||
## Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Next.js 15**: React framework with App Router
|
||||
- **TypeScript**: Type-safe development
|
||||
- **Tailwind CSS**: Utility-first CSS framework
|
||||
- **Framer Motion**: Animation library
|
||||
- **React Icons**: Icon components
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a demo payment system - no real charges are processed
|
||||
- Audio files are not included - add your own MP3 files
|
||||
- Album covers are placeholders - replace with actual images
|
||||
- Purchase data is stored locally in the browser
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Author
|
||||
|
||||
Built for Parsa - Progressive Rock Composer & Producer
|
||||
294
app/album/[id]/page.tsx
Normal file
294
app/album/[id]/page.tsx
Normal file
@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { motion } from 'framer-motion';
|
||||
import { FaPlay, FaShoppingCart, FaArrowLeft, FaClock, FaMusic } from 'react-icons/fa';
|
||||
import { albums } from '@/lib/data';
|
||||
import { Album, Purchase } from '@/lib/types';
|
||||
import { useCart } from '@/lib/CartContext';
|
||||
import Header from '@/components/Header';
|
||||
import CartSidebar from '@/components/CartSidebar';
|
||||
import MusicPlayer from '@/components/MusicPlayer';
|
||||
import PaymentModal from '@/components/PaymentModal';
|
||||
import PurchaseSuccessModal from '@/components/PurchaseSuccessModal';
|
||||
|
||||
export default function AlbumDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { addToCart, isInCart } = useCart();
|
||||
const [album, setAlbum] = useState<Album | null>(null);
|
||||
const [purchasedAlbums, setPurchasedAlbums] = useState<string[]>([]);
|
||||
const [purchases, setPurchases] = useState<Purchase[]>([]);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [currentAlbum, setCurrentAlbum] = useState<Album | null>(null);
|
||||
const [albumToPurchase, setAlbumToPurchase] = useState<Album | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [latestPurchase, setLatestPurchase] = useState<Purchase | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const foundAlbum = albums.find((a) => a.id === params.id);
|
||||
setAlbum(foundAlbum || null);
|
||||
|
||||
// Load purchases
|
||||
const savedPurchases = localStorage.getItem('purchases');
|
||||
if (savedPurchases) {
|
||||
const parsedPurchases = JSON.parse(savedPurchases);
|
||||
setPurchases(parsedPurchases);
|
||||
setPurchasedAlbums(parsedPurchases.map((p: Purchase) => p.albumId));
|
||||
}
|
||||
}, [params.id]);
|
||||
|
||||
const handlePurchaseSuccess = (albumId: string, transactionId: string) => {
|
||||
const purchase: Purchase = {
|
||||
albumId,
|
||||
transactionId,
|
||||
purchaseDate: new Date(),
|
||||
};
|
||||
|
||||
const updatedPurchases = [...purchases, purchase];
|
||||
setPurchases(updatedPurchases);
|
||||
setPurchasedAlbums([...purchasedAlbums, albumId]);
|
||||
setLatestPurchase(purchase);
|
||||
|
||||
localStorage.setItem('purchases', JSON.stringify(updatedPurchases));
|
||||
|
||||
setAlbumToPurchase(null);
|
||||
setShowSuccessModal(true);
|
||||
};
|
||||
|
||||
const handleCloseSuccessModal = () => {
|
||||
setShowSuccessModal(false);
|
||||
if (albumToPurchase) {
|
||||
setCurrentAlbum(albumToPurchase);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (album && !isInCart(album.id)) {
|
||||
addToCart(album);
|
||||
}
|
||||
};
|
||||
|
||||
const isPurchased = album ? purchasedAlbums.includes(album.id) : false;
|
||||
const inCart = album ? isInCart(album.id) : false;
|
||||
|
||||
if (!album) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-white mb-4">Album not found</h1>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="px-6 py-3 bg-accent-cyan hover:bg-accent-cyan/80 rounded-lg text-white transition-all"
|
||||
>
|
||||
Go Back Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalDuration = album.songs.reduce((total, song) => {
|
||||
const [minutes, seconds] = song.duration.split(':').map(Number);
|
||||
return total + minutes * 60 + seconds;
|
||||
}, 0);
|
||||
|
||||
const formatTotalDuration = (seconds: number) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
return `${mins} min`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header onCartClick={() => setCartOpen(true)} />
|
||||
<div className="min-h-screen pt-20">
|
||||
{/* Back Button */}
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-6">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-accent-cyan transition-colors"
|
||||
>
|
||||
<FaArrowLeft />
|
||||
Back to Albums
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Album Header */}
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pb-12">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-start">
|
||||
{/* Album Cover */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="aspect-square rounded-2xl overflow-hidden bg-gradient-to-br from-primary-600/50 to-primary-800/50 flex items-center justify-center border-2 border-accent-cyan/50 glow-cyan">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-accent-cyan/20 to-accent-orange/20"></div>
|
||||
<div className="relative z-10 text-6xl md:text-8xl font-bold text-white/20 p-8 text-center">
|
||||
{album.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPurchased && (
|
||||
<div className="absolute top-6 right-6 bg-accent-cyan text-white px-4 py-2 rounded-full text-sm font-semibold">
|
||||
Owned
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Album Info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-2">{album.year} • {album.genre}</p>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan to-accent-orange mb-4">
|
||||
{album.title}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 leading-relaxed">
|
||||
{album.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Album Stats */}
|
||||
<div className="flex gap-6 text-gray-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<FaMusic className="text-accent-cyan" />
|
||||
<span>{album.songs.length} tracks</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FaClock className="text-accent-cyan" />
|
||||
<span>{formatTotalDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
{!isPurchased && (
|
||||
<div className="text-3xl font-bold text-accent-orange">
|
||||
${album.price}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
onClick={() => setCurrentAlbum(album)}
|
||||
className="flex-1 py-4 bg-accent-cyan hover:bg-accent-cyan/80 rounded-lg font-semibold text-white transition-all glow-cyan flex items-center justify-center gap-2"
|
||||
>
|
||||
<FaPlay />
|
||||
{isPurchased ? 'Play Album' : 'Preview'}
|
||||
</button>
|
||||
|
||||
{!isPurchased && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setAlbumToPurchase(album)}
|
||||
className="flex-1 py-4 bg-accent-orange hover:bg-accent-orange/80 rounded-lg font-semibold text-white transition-all glow-orange flex items-center justify-center gap-2"
|
||||
>
|
||||
Buy Now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={inCart}
|
||||
className={`py-4 px-6 ${
|
||||
inCart
|
||||
? 'bg-gray-700 cursor-not-allowed'
|
||||
: '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`}
|
||||
>
|
||||
<FaShoppingCart />
|
||||
{inCart ? 'In Cart' : 'Add to Cart'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track List */}
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pb-20">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="glass-effect rounded-2xl p-8"
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Track List</h2>
|
||||
<div className="space-y-2">
|
||||
{album.songs.map((song, index) => (
|
||||
<motion.div
|
||||
key={song.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.05 }}
|
||||
className="flex items-center justify-between p-4 rounded-lg hover:bg-white/5 transition-colors group cursor-pointer"
|
||||
onClick={() => setCurrentAlbum(album)}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<span className="text-gray-500 font-mono text-sm w-8">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white font-medium group-hover:text-accent-cyan transition-colors">
|
||||
{song.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
{!isPurchased && (
|
||||
<span className="text-xs text-accent-orange px-2 py-1 bg-accent-orange/20 rounded">
|
||||
Preview
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400 text-sm font-mono">
|
||||
{song.duration}
|
||||
</span>
|
||||
<FaPlay className="text-accent-cyan opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Music Player */}
|
||||
{currentAlbum && (
|
||||
<MusicPlayer
|
||||
album={currentAlbum}
|
||||
isPurchased={purchasedAlbums.includes(currentAlbum.id)}
|
||||
onClose={() => setCurrentAlbum(null)}
|
||||
onPurchase={setAlbumToPurchase}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Payment Modal */}
|
||||
<PaymentModal
|
||||
album={albumToPurchase}
|
||||
onClose={() => setAlbumToPurchase(null)}
|
||||
onSuccess={handlePurchaseSuccess}
|
||||
/>
|
||||
|
||||
{/* Purchase Success Modal */}
|
||||
<PurchaseSuccessModal
|
||||
show={showSuccessModal}
|
||||
album={albumToPurchase}
|
||||
purchase={latestPurchase}
|
||||
onClose={handleCloseSuccessModal}
|
||||
/>
|
||||
|
||||
{/* Cart Sidebar */}
|
||||
<CartSidebar isOpen={cartOpen} onClose={() => setCartOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
app/globals.css
Normal file
23
app/globals.css
Normal file
@ -0,0 +1,23 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gradient-to-br from-primary-900 via-primary-800 to-primary-900 text-white min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass-effect {
|
||||
@apply bg-white/10 backdrop-blur-md border border-white/20;
|
||||
}
|
||||
|
||||
.glow-orange {
|
||||
box-shadow: 0 0 20px rgba(255, 107, 53, 0.5);
|
||||
}
|
||||
|
||||
.glow-cyan {
|
||||
box-shadow: 0 0 20px rgba(0, 217, 255, 0.5);
|
||||
}
|
||||
}
|
||||
26
app/layout.tsx
Normal file
26
app/layout.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { CartProvider } from '@/lib/CartContext';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Parsa - Progressive Rock Composer',
|
||||
description: 'Explore progressive rock albums by composer and producer Parsa. Intricate compositions, powerful instrumentation, and sonic landscapes.',
|
||||
keywords: ['progressive rock', 'prog rock', 'music', 'composer', 'producer', 'albums'],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
154
app/page.tsx
Normal file
154
app/page.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Header from '@/components/Header';
|
||||
import Biography from '@/components/Biography';
|
||||
import AlbumShowcase from '@/components/AlbumShowcase';
|
||||
import MusicPlayer from '@/components/MusicPlayer';
|
||||
import PaymentModal from '@/components/PaymentModal';
|
||||
import PurchaseSuccessModal from '@/components/PurchaseSuccessModal';
|
||||
import CartSidebar from '@/components/CartSidebar';
|
||||
import { Album, Purchase } from '@/lib/types';
|
||||
|
||||
export default function Home() {
|
||||
const [purchasedAlbums, setPurchasedAlbums] = useState<string[]>([]);
|
||||
const [purchases, setPurchases] = useState<Purchase[]>([]);
|
||||
const [currentAlbum, setCurrentAlbum] = useState<Album | null>(null);
|
||||
const [albumToPurchase, setAlbumToPurchase] = useState<Album | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [latestPurchase, setLatestPurchase] = useState<Purchase | null>(null);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
|
||||
// Load purchases from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedPurchases = localStorage.getItem('purchases');
|
||||
if (savedPurchases) {
|
||||
const parsedPurchases = JSON.parse(savedPurchases);
|
||||
setPurchases(parsedPurchases);
|
||||
setPurchasedAlbums(parsedPurchases.map((p: Purchase) => p.albumId));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePlayAlbum = (album: Album) => {
|
||||
setCurrentAlbum(album);
|
||||
};
|
||||
|
||||
const handlePurchaseClick = (album: Album) => {
|
||||
setAlbumToPurchase(album);
|
||||
};
|
||||
|
||||
const handlePurchaseSuccess = (albumId: string, transactionId: string) => {
|
||||
const purchase: Purchase = {
|
||||
albumId,
|
||||
transactionId,
|
||||
purchaseDate: new Date(),
|
||||
};
|
||||
|
||||
const updatedPurchases = [...purchases, purchase];
|
||||
setPurchases(updatedPurchases);
|
||||
setPurchasedAlbums([...purchasedAlbums, albumId]);
|
||||
setLatestPurchase(purchase);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('purchases', JSON.stringify(updatedPurchases));
|
||||
|
||||
// Close payment modal and show success modal
|
||||
setAlbumToPurchase(null);
|
||||
setShowSuccessModal(true);
|
||||
};
|
||||
|
||||
const handleCloseSuccessModal = () => {
|
||||
setShowSuccessModal(false);
|
||||
// Automatically open the player with the purchased album
|
||||
if (albumToPurchase) {
|
||||
setCurrentAlbum(albumToPurchase);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header onCartClick={() => setCartOpen(true)} />
|
||||
|
||||
<main className="pt-20">
|
||||
{/* Hero Section */}
|
||||
<section className="min-h-[60vh] flex items-center justify-center px-4 md:px-8 relative overflow-hidden">
|
||||
{/* Animated Background */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent-cyan/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-accent-orange/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }}></div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center max-w-4xl mx-auto">
|
||||
<h1 className="text-5xl md:text-7xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan via-white to-accent-orange mb-6">
|
||||
Progressive Rock
|
||||
<br />
|
||||
Reimagined
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-gray-300 mb-8">
|
||||
Explore intricate compositions and sonic landscapes
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
const element = document.getElementById('albums');
|
||||
element?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
className="px-8 py-4 bg-gradient-to-r from-accent-cyan to-accent-orange hover:from-accent-cyan/80 hover:to-accent-orange/80 rounded-full font-semibold text-white transition-all glow-cyan text-lg"
|
||||
>
|
||||
Explore Albums
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Biography Section */}
|
||||
<Biography />
|
||||
|
||||
{/* Albums Section */}
|
||||
<AlbumShowcase
|
||||
purchasedAlbums={purchasedAlbums}
|
||||
onPlay={handlePlayAlbum}
|
||||
onPurchase={handlePurchaseClick}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-4 md:px-8 border-t border-white/10 mt-20">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
<p className="text-gray-400">
|
||||
© {new Date().getFullYear()} Parsa. All rights reserved.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Progressive Rock Composer & Producer
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
{/* Music Player */}
|
||||
{currentAlbum && (
|
||||
<MusicPlayer
|
||||
album={currentAlbum}
|
||||
isPurchased={purchasedAlbums.includes(currentAlbum.id)}
|
||||
onClose={() => setCurrentAlbum(null)}
|
||||
onPurchase={handlePurchaseClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Payment Modal */}
|
||||
<PaymentModal
|
||||
album={albumToPurchase}
|
||||
onClose={() => setAlbumToPurchase(null)}
|
||||
onSuccess={handlePurchaseSuccess}
|
||||
/>
|
||||
|
||||
{/* Purchase Success Modal */}
|
||||
<PurchaseSuccessModal
|
||||
show={showSuccessModal}
|
||||
album={albumToPurchase}
|
||||
purchase={latestPurchase}
|
||||
onClose={handleCloseSuccessModal}
|
||||
/>
|
||||
|
||||
{/* Cart Sidebar */}
|
||||
<CartSidebar isOpen={cartOpen} onClose={() => setCartOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
components/AlbumCard.tsx
Normal file
118
components/AlbumCard.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FaPlay, FaShoppingCart, FaInfoCircle } from 'react-icons/fa';
|
||||
import { Album } from '@/lib/types';
|
||||
import { useCart } from '@/lib/CartContext';
|
||||
|
||||
interface AlbumCardProps {
|
||||
album: Album;
|
||||
isPurchased: boolean;
|
||||
onPlay: (album: Album) => void;
|
||||
onPurchase: (album: Album) => void;
|
||||
}
|
||||
|
||||
export default function AlbumCard({ album, isPurchased, onPlay, onPurchase }: AlbumCardProps) {
|
||||
const router = useRouter();
|
||||
const { addToCart, isInCart } = useCart();
|
||||
|
||||
const handleAddToCart = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isInCart(album.id) && !isPurchased) {
|
||||
addToCart(album);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
router.push(`/album/${album.id}`);
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
router.push(`/album/${album.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ y: -10 }}
|
||||
onClick={handleCardClick}
|
||||
className="glass-effect rounded-xl overflow-hidden group cursor-pointer"
|
||||
>
|
||||
{/* Album Cover */}
|
||||
<div className="relative aspect-square bg-gradient-to-br from-primary-600/50 to-primary-800/50 flex items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-accent-cyan/20 to-accent-orange/20"></div>
|
||||
<div className="relative z-10 text-6xl font-bold text-white/20 p-8 text-center">
|
||||
{album.title}
|
||||
</div>
|
||||
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all duration-300 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlay(album);
|
||||
}}
|
||||
className="p-4 bg-accent-cyan hover:bg-accent-cyan/80 rounded-full transition-all glow-cyan"
|
||||
aria-label="Play preview"
|
||||
>
|
||||
<FaPlay className="text-2xl text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleViewDetails(e)}
|
||||
className="p-4 bg-white/20 hover:bg-white/30 rounded-full transition-all"
|
||||
aria-label="View details"
|
||||
>
|
||||
<FaInfoCircle className="text-2xl text-white" />
|
||||
</button>
|
||||
{!isPurchased && (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isInCart(album.id)}
|
||||
className={`p-4 ${
|
||||
isInCart(album.id)
|
||||
? 'bg-gray-700 cursor-not-allowed'
|
||||
: 'bg-accent-orange hover:bg-accent-orange/80 glow-orange'
|
||||
} rounded-full transition-all`}
|
||||
aria-label="Add to cart"
|
||||
>
|
||||
<FaShoppingCart className="text-2xl text-white" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Purchased Badge */}
|
||||
{isPurchased && (
|
||||
<div className="absolute top-4 right-4 bg-accent-cyan text-white px-3 py-1 rounded-full text-sm font-semibold">
|
||||
Owned
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Album Info */}
|
||||
<div className="p-6 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white group-hover:text-accent-cyan transition-colors">
|
||||
{album.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">{album.year} • {album.genre}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 text-sm line-clamp-2">{album.description}</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm text-gray-400">{album.songs.length} tracks</span>
|
||||
{!isPurchased && (
|
||||
<span className="text-lg font-bold text-accent-orange">${album.price}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
47
components/AlbumShowcase.tsx
Normal file
47
components/AlbumShowcase.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import AlbumCard from './AlbumCard';
|
||||
import { Album } from '@/lib/types';
|
||||
import { albums } from '@/lib/data';
|
||||
|
||||
interface AlbumShowcaseProps {
|
||||
purchasedAlbums: string[];
|
||||
onPlay: (album: Album) => void;
|
||||
onPurchase: (album: Album) => void;
|
||||
}
|
||||
|
||||
export default function AlbumShowcase({ purchasedAlbums, onPlay, onPurchase }: AlbumShowcaseProps) {
|
||||
return (
|
||||
<section className="py-20 px-4 md:px-8" id="albums">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan to-accent-orange mb-4">
|
||||
Discography
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||
Explore a collection of progressive rock albums that push the boundaries of sound and creativity
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{albums.map((album) => (
|
||||
<AlbumCard
|
||||
key={album.id}
|
||||
album={album}
|
||||
isPurchased={purchasedAlbums.includes(album.id)}
|
||||
onPlay={onPlay}
|
||||
onPurchase={onPurchase}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
104
components/Biography.tsx
Normal file
104
components/Biography.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { FaSpotify, FaYoutube, FaInstagram } from 'react-icons/fa';
|
||||
import { SiApplemusic } from 'react-icons/si';
|
||||
import { artistBio } from '@/lib/data';
|
||||
|
||||
export default function Biography() {
|
||||
return (
|
||||
<section className="py-20 px-4 md:px-8" id="bio">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="glass-effect rounded-2xl p-8 md:p-12"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
{/* Image Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="aspect-square rounded-xl overflow-hidden bg-gradient-to-br from-accent-orange/20 to-accent-cyan/20 flex items-center justify-center border-2 border-accent-cyan/50 glow-cyan">
|
||||
<div className="text-6xl md:text-8xl font-bold text-accent-cyan/30">
|
||||
{artistBio.name}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bio Section */}
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan to-accent-orange mb-2">
|
||||
{artistBio.name}
|
||||
</h1>
|
||||
<p className="text-xl text-accent-cyan/80 mb-6">{artistBio.title}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
className="space-y-4 text-gray-300 leading-relaxed"
|
||||
>
|
||||
{artistBio.bio.split('\n\n').map((paragraph, idx) => (
|
||||
<p key={idx}>{paragraph}</p>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Social Links */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex gap-4 pt-6"
|
||||
>
|
||||
<a
|
||||
href={artistBio.socialLinks.spotify}
|
||||
className="p-3 glass-effect rounded-lg hover:bg-accent-cyan/20 transition-all hover:glow-cyan"
|
||||
aria-label="Spotify"
|
||||
>
|
||||
<FaSpotify className="text-2xl text-accent-cyan" />
|
||||
</a>
|
||||
<a
|
||||
href={artistBio.socialLinks.youtube}
|
||||
className="p-3 glass-effect rounded-lg hover:bg-accent-orange/20 transition-all hover:glow-orange"
|
||||
aria-label="YouTube"
|
||||
>
|
||||
<FaYoutube className="text-2xl text-accent-orange" />
|
||||
</a>
|
||||
<a
|
||||
href={artistBio.socialLinks.instagram}
|
||||
className="p-3 glass-effect rounded-lg hover:bg-accent-cyan/20 transition-all hover:glow-cyan"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<FaInstagram className="text-2xl text-accent-cyan" />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="p-3 glass-effect rounded-lg hover:bg-accent-orange/20 transition-all hover:glow-orange"
|
||||
aria-label="Apple Music"
|
||||
>
|
||||
<SiApplemusic className="text-2xl text-accent-orange" />
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
208
components/CartSidebar.tsx
Normal file
208
components/CartSidebar.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaTimes, FaTrash, FaShoppingCart } from 'react-icons/fa';
|
||||
import { useCart } from '@/lib/CartContext';
|
||||
import { Album, Purchase } from '@/lib/types';
|
||||
import PaymentModal from './PaymentModal';
|
||||
import PurchaseSuccessModal from './PurchaseSuccessModal';
|
||||
|
||||
interface CartSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CartSidebar({ isOpen, onClose }: CartSidebarProps) {
|
||||
const { cartItems, removeFromCart, clearCart, getCartTotal } = useCart();
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [albumToPurchase, setAlbumToPurchase] = useState<Album | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [latestPurchase, setLatestPurchase] = useState<Purchase | null>(null);
|
||||
const [purchasedAlbums, setPurchasedAlbums] = useState<string[]>([]);
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (cartItems.length === 0) return;
|
||||
|
||||
// For simplicity, we'll purchase the first item
|
||||
// In a real app, you'd handle multiple items differently
|
||||
if (cartItems.length > 0) {
|
||||
setAlbumToPurchase(cartItems[0].album);
|
||||
setShowPaymentModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePurchaseSuccess = (albumId: string, transactionId: string) => {
|
||||
const purchase: Purchase = {
|
||||
albumId,
|
||||
transactionId,
|
||||
purchaseDate: new Date(),
|
||||
};
|
||||
|
||||
// Load existing purchases
|
||||
const savedPurchases = localStorage.getItem('purchases');
|
||||
const existingPurchases = savedPurchases ? JSON.parse(savedPurchases) : [];
|
||||
const updatedPurchases = [...existingPurchases, purchase];
|
||||
|
||||
localStorage.setItem('purchases', JSON.stringify(updatedPurchases));
|
||||
setPurchasedAlbums([...purchasedAlbums, albumId]);
|
||||
setLatestPurchase(purchase);
|
||||
|
||||
// Remove from cart
|
||||
removeFromCart(albumId);
|
||||
|
||||
setShowPaymentModal(false);
|
||||
setShowSuccessModal(true);
|
||||
};
|
||||
|
||||
const handleCloseSuccessModal = () => {
|
||||
setShowSuccessModal(false);
|
||||
setAlbumToPurchase(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
|
||||
/>
|
||||
|
||||
{/* Sidebar */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed right-0 top-0 bottom-0 w-full md:w-96 glass-effect border-l border-white/10 z-50 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<FaShoppingCart className="text-2xl text-accent-cyan" />
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Cart ({cartItems.length})
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
||||
aria-label="Close cart"
|
||||
>
|
||||
<FaTimes className="text-xl text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{cartItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<FaShoppingCart className="text-6xl text-gray-600 mb-4" />
|
||||
<p className="text-gray-400 text-lg">Your cart is empty</p>
|
||||
<p className="text-gray-500 text-sm mt-2">Add some albums to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{cartItems.map((item) => (
|
||||
<motion.div
|
||||
key={item.album.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="glass-effect rounded-xl p-4"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{/* Album Cover */}
|
||||
<div className="w-20 h-20 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">
|
||||
{item.album.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Album Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-semibold truncate">
|
||||
{item.album.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
{item.album.year} • {item.album.songs.length} tracks
|
||||
</p>
|
||||
<p className="text-accent-orange font-bold mt-2">
|
||||
${item.album.price}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<button
|
||||
onClick={() => removeFromCart(item.album.id)}
|
||||
className="p-2 hover:bg-red-500/20 rounded-lg transition-colors self-start"
|
||||
aria-label="Remove from cart"
|
||||
>
|
||||
<FaTrash className="text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{cartItems.length > 0 && (
|
||||
<div className="border-t border-white/10 p-6 space-y-4">
|
||||
{/* Total */}
|
||||
<div className="flex items-center justify-between text-xl">
|
||||
<span className="text-gray-300 font-semibold">Total</span>
|
||||
<span className="text-accent-orange font-bold">
|
||||
${getCartTotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
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"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</button>
|
||||
<button
|
||||
onClick={clearCart}
|
||||
className="w-full py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg font-semibold text-gray-300 transition-all"
|
||||
>
|
||||
Clear Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Payment Modal */}
|
||||
<PaymentModal
|
||||
album={albumToPurchase}
|
||||
onClose={() => {
|
||||
setShowPaymentModal(false);
|
||||
setAlbumToPurchase(null);
|
||||
}}
|
||||
onSuccess={handlePurchaseSuccess}
|
||||
/>
|
||||
|
||||
{/* Purchase Success Modal */}
|
||||
<PurchaseSuccessModal
|
||||
show={showSuccessModal}
|
||||
album={albumToPurchase}
|
||||
purchase={latestPurchase}
|
||||
onClose={handleCloseSuccessModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
components/Header.tsx
Normal file
88
components/Header.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { FaMusic, FaShoppingCart } from 'react-icons/fa';
|
||||
import { useCart } from '@/lib/CartContext';
|
||||
|
||||
interface HeaderProps {
|
||||
onCartClick?: () => void;
|
||||
}
|
||||
|
||||
export default function Header({ onCartClick }: HeaderProps) {
|
||||
const { getCartCount } = useCart();
|
||||
const cartCount = getCartCount();
|
||||
|
||||
const scrollToSection = (id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
className="fixed top-0 left-0 right-0 z-40 glass-effect border-b border-white/10"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
<div className="p-2 bg-gradient-to-br from-accent-cyan to-accent-orange rounded-lg glow-cyan">
|
||||
<FaMusic className="text-2xl text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-cyan to-accent-orange">
|
||||
Parsa
|
||||
</h1>
|
||||
<p className="text-xs text-gray-400">Progressive Rock</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex items-center gap-6">
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<button
|
||||
onClick={() => scrollToSection('bio')}
|
||||
className="text-gray-300 hover:text-accent-cyan transition-colors"
|
||||
>
|
||||
Bio
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scrollToSection('albums')}
|
||||
className="text-gray-300 hover:text-accent-cyan transition-colors"
|
||||
>
|
||||
Albums
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cart Button */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={onCartClick}
|
||||
className="relative p-3 bg-white/10 hover:bg-white/20 rounded-lg transition-all"
|
||||
aria-label="Shopping cart"
|
||||
>
|
||||
<FaShoppingCart className="text-xl text-accent-cyan" />
|
||||
{cartCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -top-1 -right-1 bg-accent-orange text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
||||
>
|
||||
{cartCount}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
337
components/MusicPlayer.tsx
Normal file
337
components/MusicPlayer.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaPlay, FaPause, FaStepBackward, FaStepForward, FaTimes, FaChevronDown, FaChevronUp } from 'react-icons/fa';
|
||||
import { Album, Song } from '@/lib/types';
|
||||
|
||||
interface MusicPlayerProps {
|
||||
album: Album | null;
|
||||
isPurchased: boolean;
|
||||
onClose: () => void;
|
||||
onPurchase: (album: Album) => void;
|
||||
}
|
||||
|
||||
export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }: MusicPlayerProps) {
|
||||
const [currentSongIndex, setCurrentSongIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [showTrackList, setShowTrackList] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const currentSong = album?.songs[currentSongIndex];
|
||||
|
||||
useEffect(() => {
|
||||
if (album) {
|
||||
setCurrentSongIndex(0);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
}
|
||||
}, [album]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||
const updateDuration = () => setDuration(audio.duration);
|
||||
const handleEnded = () => {
|
||||
if (!isPurchased) {
|
||||
// Preview ended
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
} else {
|
||||
// Move to next song
|
||||
handleNext();
|
||||
}
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', updateTime);
|
||||
audio.addEventListener('loadedmetadata', updateDuration);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', updateTime);
|
||||
audio.removeEventListener('loadedmetadata', updateDuration);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, [isPurchased, currentSongIndex, album]);
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!album) return;
|
||||
const nextIndex = (currentSongIndex + 1) % album.songs.length;
|
||||
setCurrentSongIndex(nextIndex);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (!album) return;
|
||||
const prevIndex = currentSongIndex === 0 ? album.songs.length - 1 : currentSongIndex - 1;
|
||||
setCurrentSongIndex(prevIndex);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !isPurchased) return; // Only allow seeking for purchased albums
|
||||
|
||||
const time = parseFloat(e.target.value);
|
||||
audio.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time)) return '0:00';
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!album) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{album && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-black/90 backdrop-blur-xl z-50"
|
||||
/>
|
||||
|
||||
{/* Player Modal */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 50 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 50 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"
|
||||
>
|
||||
<div className="w-full max-w-md pointer-events-auto relative">
|
||||
{/* Close Button - Top Right */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={onClose}
|
||||
className="absolute -top-2 -right-2 z-10 p-3 bg-white/90 hover:bg-white rounded-full transition-colors shadow-xl"
|
||||
aria-label="Close player"
|
||||
>
|
||||
<FaTimes className="text-xl text-primary-900" />
|
||||
</motion.button>
|
||||
|
||||
{/* Album Artwork */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="relative aspect-square rounded-2xl overflow-hidden mb-6 shadow-2xl"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-500 via-primary-700 to-primary-900"></div>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-accent-cyan/30 to-accent-orange/30"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-4xl md:text-5xl font-bold text-white/30 text-center px-6">
|
||||
{album.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Badge */}
|
||||
{!isPurchased && (
|
||||
<div className="absolute top-3 right-3 bg-accent-orange/90 backdrop-blur-sm text-white px-3 py-1 rounded-full text-xs font-semibold shadow-lg">
|
||||
Preview Mode
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Song Info */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-center mb-6"
|
||||
>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-white mb-1">
|
||||
{currentSong?.title}
|
||||
</h2>
|
||||
<p className="text-base text-gray-400">
|
||||
{album.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Track {currentSongIndex + 1} of {album.songs.length}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mb-5"
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
disabled={!isPurchased}
|
||||
className="w-full h-1 bg-gray-700/50 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #00d9ff ${(currentTime / (duration || 1)) * 100}%, rgba(255,255,255,0.1) ${(currentTime / (duration || 1)) * 100}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1.5 font-mono">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{isPurchased ? formatTime(duration) : '0:30'}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Controls */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="flex items-center justify-center gap-6 mb-6"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={handlePrevious}
|
||||
className="p-3 hover:bg-white/10 rounded-full transition-colors"
|
||||
aria-label="Previous track"
|
||||
>
|
||||
<FaStepBackward className="text-xl text-white" />
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={togglePlay}
|
||||
className="p-5 bg-white hover:bg-gray-100 rounded-full transition-all shadow-2xl"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<FaPause className="text-2xl text-primary-900" />
|
||||
) : (
|
||||
<FaPlay className="text-2xl text-primary-900 ml-0.5" />
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={handleNext}
|
||||
className="p-3 hover:bg-white/10 rounded-full transition-colors"
|
||||
aria-label="Next track"
|
||||
>
|
||||
<FaStepForward className="text-xl text-white" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
|
||||
{/* Purchase Button */}
|
||||
{!isPurchased && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => onPurchase(album)}
|
||||
className="w-full mb-3 py-3 bg-gradient-to-r from-accent-orange to-accent-orange/80 hover:from-accent-orange/90 hover:to-accent-orange/70 rounded-xl font-semibold text-white text-base transition-all shadow-xl glow-orange"
|
||||
>
|
||||
Purchase Full Album - ${album.price}
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* Track List Toggle */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowTrackList(!showTrackList)}
|
||||
className="w-full py-2.5 bg-white/5 hover:bg-white/10 rounded-lg transition-colors flex items-center justify-center gap-2 text-gray-300 text-sm"
|
||||
>
|
||||
{showTrackList ? <FaChevronUp className="text-xs" /> : <FaChevronDown className="text-xs" />}
|
||||
<span className="font-medium">
|
||||
{showTrackList ? 'Hide' : 'Show'} Track List
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showTrackList && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 max-h-52 overflow-y-auto rounded-lg bg-white/5 p-2">
|
||||
<div className="space-y-0.5">
|
||||
{album.songs.map((song, index) => (
|
||||
<button
|
||||
key={song.id}
|
||||
onClick={() => {
|
||||
setCurrentSongIndex(index);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-md transition-all ${
|
||||
index === currentSongIndex
|
||||
? 'bg-accent-cyan/20 text-accent-cyan'
|
||||
: 'hover:bg-white/5 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 font-mono w-5">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{song.title}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono">{song.duration}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Hidden Audio Element */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={isPurchased ? currentSong?.fullUrl : currentSong?.previewUrl}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
222
components/PaymentModal.tsx
Normal file
222
components/PaymentModal.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaTimes, FaCreditCard, FaLock } from 'react-icons/fa';
|
||||
import { Album } from '@/lib/types';
|
||||
|
||||
interface PaymentModalProps {
|
||||
album: Album | null;
|
||||
onClose: () => void;
|
||||
onSuccess: (albumId: string, transactionId: string) => void;
|
||||
}
|
||||
|
||||
export default function PaymentModal({ album, onClose, onSuccess }: PaymentModalProps) {
|
||||
const [cardNumber, setCardNumber] = useState('');
|
||||
const [cardName, setCardName] = useState('');
|
||||
const [expiry, setExpiry] = useState('');
|
||||
const [cvv, setCvv] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const formatCardNumber = (value: string) => {
|
||||
const numbers = value.replace(/\s/g, '');
|
||||
const groups = numbers.match(/.{1,4}/g);
|
||||
return groups ? groups.join(' ') : numbers;
|
||||
};
|
||||
|
||||
const formatExpiry = (value: string) => {
|
||||
const numbers = value.replace(/\D/g, '');
|
||||
if (numbers.length >= 2) {
|
||||
return numbers.slice(0, 2) + '/' + numbers.slice(2, 4);
|
||||
}
|
||||
return numbers;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Basic validation
|
||||
if (cardNumber.replace(/\s/g, '').length !== 16) {
|
||||
setError('Invalid card number');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cardName.trim()) {
|
||||
setError('Card name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (expiry.length !== 5) {
|
||||
setError('Invalid expiry date');
|
||||
return;
|
||||
}
|
||||
|
||||
if (cvv.length !== 3 && cvv.length !== 4) {
|
||||
setError('Invalid CVV');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
// Simulate payment processing
|
||||
setTimeout(() => {
|
||||
// Generate a mock transaction ID
|
||||
const transactionId = 'TXN-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9).toUpperCase();
|
||||
|
||||
setProcessing(false);
|
||||
if (album) {
|
||||
onSuccess(album.id, transactionId);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
if (!album) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{album && (
|
||||
<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-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">
|
||||
Purchase Album
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
{/* Album Info */}
|
||||
<div className="mb-6 p-4 bg-white/5 rounded-lg">
|
||||
<h3 className="font-semibold text-white">{album.title}</h3>
|
||||
<p className="text-sm text-gray-400">{album.songs.length} tracks • {album.genre}</p>
|
||||
<p className="text-2xl font-bold text-accent-orange mt-2">${album.price}</p>
|
||||
</div>
|
||||
|
||||
{/* Payment Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Card Number */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<FaCreditCard className="inline mr-2" />
|
||||
Card Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cardNumber}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Name on Card
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cardName}
|
||||
onChange={(e) => setCardName(e.target.value.toUpperCase())}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expiry and CVV */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Expiry Date
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={expiry}
|
||||
onChange={(e) => setExpiry(formatExpiry(e.target.value))}
|
||||
placeholder="MM/YY"
|
||||
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">
|
||||
CVV
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cvv}
|
||||
onChange={(e) => setCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
placeholder="123"
|
||||
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 && (
|
||||
<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 */}
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaLock />
|
||||
Complete Purchase - ${album.price}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Security Notice */}
|
||||
<p className="text-xs text-gray-500 text-center mt-4">
|
||||
<FaLock className="inline mr-1" />
|
||||
This is a demo payment form. No real charges will be made.
|
||||
</p>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
145
components/PurchaseSuccessModal.tsx
Normal file
145
components/PurchaseSuccessModal.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FaCheckCircle, FaTimes, FaDownload } from 'react-icons/fa';
|
||||
import { Album, Purchase } from '@/lib/types';
|
||||
|
||||
interface PurchaseSuccessModalProps {
|
||||
show: boolean;
|
||||
album: Album | null;
|
||||
purchase: Purchase | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PurchaseSuccessModal({ show, album, purchase, onClose }: PurchaseSuccessModalProps) {
|
||||
if (!album || !purchase) return null;
|
||||
|
||||
const handleDownloadReceipt = () => {
|
||||
// Create a simple text receipt
|
||||
const receipt = `
|
||||
PURCHASE RECEIPT
|
||||
================
|
||||
|
||||
Album: ${album.title}
|
||||
Artist: Parsa
|
||||
Price: $${album.price}
|
||||
Transaction ID: ${purchase.transactionId}
|
||||
Date: ${new Date(purchase.purchaseDate).toLocaleString()}
|
||||
|
||||
Tracks Included:
|
||||
${album.songs.map((song, idx) => `${idx + 1}. ${song.title} (${song.duration})`).join('\n')}
|
||||
|
||||
Thank you for your purchase!
|
||||
You now have full access to this album.
|
||||
`;
|
||||
|
||||
const blob = new Blob([receipt], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `receipt-${purchase.transactionId}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
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, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="glass-effect rounded-2xl max-w-lg w-full p-8 border-2 border-accent-cyan/30"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-green-500/20 rounded-full">
|
||||
<FaCheckCircle className="text-3xl text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">
|
||||
Purchase Successful!
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400">Thank you for your purchase</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>
|
||||
|
||||
{/* Receipt Details */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="p-4 bg-white/5 rounded-lg space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Album</span>
|
||||
<span className="text-white font-semibold">{album.title}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Artist</span>
|
||||
<span className="text-white font-semibold">Parsa</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Tracks</span>
|
||||
<span className="text-white font-semibold">{album.songs.length} songs</span>
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-3 flex justify-between">
|
||||
<span className="text-gray-400">Total Paid</span>
|
||||
<span className="text-accent-orange font-bold text-xl">${album.price}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-accent-cyan/10 border border-accent-cyan/30 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">Transaction ID</p>
|
||||
<p className="text-sm font-mono text-accent-cyan">{purchase.transactionId}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{new Date(purchase.purchaseDate).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<FaDownload />
|
||||
Download Receipt
|
||||
</button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
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"
|
||||
>
|
||||
Start Listening
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="text-xs text-gray-500 text-center mt-6">
|
||||
You now have unlimited access to all tracks in this album
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
93
lib/CartContext.tsx
Normal file
93
lib/CartContext.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Album } from './types';
|
||||
|
||||
interface CartItem {
|
||||
album: Album;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface CartContextType {
|
||||
cartItems: CartItem[];
|
||||
addToCart: (album: Album) => void;
|
||||
removeFromCart: (albumId: string) => void;
|
||||
clearCart: () => void;
|
||||
getCartTotal: () => number;
|
||||
getCartCount: () => number;
|
||||
isInCart: (albumId: string) => boolean;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextType | undefined>(undefined);
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||
|
||||
// Load cart from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedCart = localStorage.getItem('cart');
|
||||
if (savedCart) {
|
||||
setCartItems(JSON.parse(savedCart));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save cart to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('cart', JSON.stringify(cartItems));
|
||||
}, [cartItems]);
|
||||
|
||||
const addToCart = (album: Album) => {
|
||||
setCartItems((prev) => {
|
||||
const existingItem = prev.find((item) => item.album.id === album.id);
|
||||
if (existingItem) {
|
||||
// Already in cart, don't add again
|
||||
return prev;
|
||||
}
|
||||
return [...prev, { album, quantity: 1 }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromCart = (albumId: string) => {
|
||||
setCartItems((prev) => prev.filter((item) => item.album.id !== albumId));
|
||||
};
|
||||
|
||||
const clearCart = () => {
|
||||
setCartItems([]);
|
||||
};
|
||||
|
||||
const getCartTotal = () => {
|
||||
return cartItems.reduce((total, item) => total + item.album.price * item.quantity, 0);
|
||||
};
|
||||
|
||||
const getCartCount = () => {
|
||||
return cartItems.reduce((count, item) => count + item.quantity, 0);
|
||||
};
|
||||
|
||||
const isInCart = (albumId: string) => {
|
||||
return cartItems.some((item) => item.album.id === albumId);
|
||||
};
|
||||
|
||||
return (
|
||||
<CartContext.Provider
|
||||
value={{
|
||||
cartItems,
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
clearCart,
|
||||
getCartTotal,
|
||||
getCartCount,
|
||||
isInCart,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCart() {
|
||||
const context = useContext(CartContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCart must be used within a CartProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
245
lib/data.ts
Normal file
245
lib/data.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { Album } from './types';
|
||||
|
||||
export const artistBio = {
|
||||
name: "Parsa",
|
||||
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.
|
||||
|
||||
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
|
||||
socialLinks: {
|
||||
spotify: "#",
|
||||
bandcamp: "#",
|
||||
youtube: "#",
|
||||
instagram: "#"
|
||||
}
|
||||
};
|
||||
|
||||
export const albums: Album[] = [
|
||||
{
|
||||
id: "echoes-of-time",
|
||||
title: "Echoes of Time",
|
||||
coverImage: "/albums/echoes-of-time.jpg",
|
||||
year: 2024,
|
||||
genre: "Progressive Rock",
|
||||
price: 12.99,
|
||||
description: "An epic journey through time and space, featuring complex polyrhythms, atmospheric keyboards, and powerful guitar solos. This concept album tells the story of humanity's relationship with time.",
|
||||
songs: [
|
||||
{
|
||||
id: "echoes-1",
|
||||
title: "Temporal Flux",
|
||||
duration: "8:45",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/echoes-1-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "echoes-2",
|
||||
title: "Clockwork Dreams",
|
||||
duration: "6:23",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/echoes-2-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "echoes-3",
|
||||
title: "The Eternal Now",
|
||||
duration: "12:17",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/echoes-3-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "echoes-4",
|
||||
title: "Yesterday's Tomorrow",
|
||||
duration: "7:56",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/echoes-4-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "echoes-5",
|
||||
title: "Echoes Fade",
|
||||
duration: "15:32",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/echoes-5-full.mp3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "crimson-horizons",
|
||||
title: "Crimson Horizons",
|
||||
coverImage: "/albums/crimson-horizons.jpg",
|
||||
year: 2023,
|
||||
genre: "Progressive Rock",
|
||||
price: 10.99,
|
||||
description: "A darker, heavier exploration of prog rock with crushing riffs and intricate instrumental passages. Features extended improvisational sections and powerful vocals.",
|
||||
songs: [
|
||||
{
|
||||
id: "crimson-1",
|
||||
title: "Red Dawn",
|
||||
duration: "9:12",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/crimson-1-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "crimson-2",
|
||||
title: "Horizon's Edge",
|
||||
duration: "7:45",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/crimson-2-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "crimson-3",
|
||||
title: "Scarlet Skies",
|
||||
duration: "11:03",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/crimson-3-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "crimson-4",
|
||||
title: "Blood Moon Rising",
|
||||
duration: "8:34",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/crimson-4-full.mp3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "synthetic-dreams",
|
||||
title: "Synthetic Dreams",
|
||||
coverImage: "/albums/synthetic-dreams.jpg",
|
||||
year: 2022,
|
||||
genre: "Progressive Rock / Electronic",
|
||||
price: 9.99,
|
||||
description: "A fusion of progressive rock and electronic elements, exploring the intersection of organic and synthetic sounds. Features vintage synthesizers and modern production.",
|
||||
songs: [
|
||||
{
|
||||
id: "synthetic-1",
|
||||
title: "Digital Awakening",
|
||||
duration: "6:54",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/synthetic-1-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "synthetic-2",
|
||||
title: "Neon Pulse",
|
||||
duration: "5:32",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/synthetic-2-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "synthetic-3",
|
||||
title: "Circuit Breaker",
|
||||
duration: "10:21",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/synthetic-3-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "synthetic-4",
|
||||
title: "Ghost in the Machine",
|
||||
duration: "8:47",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/synthetic-4-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "synthetic-5",
|
||||
title: "Synthetic Dreams",
|
||||
duration: "13:29",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/synthetic-5-full.mp3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "showbiz",
|
||||
title: "Showbiz",
|
||||
coverImage: "/albums/showbiz.jpg",
|
||||
year: 1999,
|
||||
genre: "Alternative Rock / Progressive Rock",
|
||||
price: 11.99,
|
||||
description: "A powerful debut album blending alternative rock with progressive elements. Features raw energy, soaring vocals, and guitar-driven anthems that established a unique sound in the late 90s rock scene.",
|
||||
songs: [
|
||||
{
|
||||
id: "showbiz-1",
|
||||
title: "Sunburn",
|
||||
duration: "3:54",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-1-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-2",
|
||||
title: "Muscle Museum",
|
||||
duration: "4:23",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-2-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-3",
|
||||
title: "Fillip",
|
||||
duration: "4:02",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-3-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-4",
|
||||
title: "Falling Down",
|
||||
duration: "4:34",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-4-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-5",
|
||||
title: "Cave",
|
||||
duration: "4:47",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-5-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-6",
|
||||
title: "Showbiz",
|
||||
duration: "5:17",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-6-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-7",
|
||||
title: "Unintended",
|
||||
duration: "3:58",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-7-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-8",
|
||||
title: "Uno",
|
||||
duration: "3:38",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-8-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-9",
|
||||
title: "Sober",
|
||||
duration: "4:04",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-9-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-10",
|
||||
title: "Escape",
|
||||
duration: "3:32",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-10-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-11",
|
||||
title: "Overdue",
|
||||
duration: "2:27",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-11-full.mp3"
|
||||
},
|
||||
{
|
||||
id: "showbiz-12",
|
||||
title: "Hate This & I'll Love You",
|
||||
duration: "5:09",
|
||||
previewUrl: "/audio/preview-1.mp3",
|
||||
fullUrl: "/audio/showbiz-12-full.mp3"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
24
lib/types.ts
Normal file
24
lib/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export interface Song {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: string;
|
||||
previewUrl: string; // URL to 30-second preview
|
||||
fullUrl: string; // URL to full song (locked until purchase)
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
coverImage: string;
|
||||
year: number;
|
||||
genre: string;
|
||||
price: number;
|
||||
description: string;
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
export interface Purchase {
|
||||
albumId: string;
|
||||
transactionId: string;
|
||||
purchaseDate: Date;
|
||||
}
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6300
package-lock.json
generated
Normal file
6300
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "parsa-songwriter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "^15.0.0",
|
||||
"framer-motion": "^11.11.17",
|
||||
"react-icons": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"postcss": "^8",
|
||||
"autoprefixer": "^10",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "15.0.0"
|
||||
}
|
||||
}
|
||||
9
postcss.config.mjs
Normal file
9
postcss.config.mjs
Normal file
@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
17
public/audio/.gitkeep
Normal file
17
public/audio/.gitkeep
Normal file
@ -0,0 +1,17 @@
|
||||
# Audio Files
|
||||
|
||||
Place your audio files here:
|
||||
- Preview files (30 seconds): preview-1.mp3, preview-2.mp3, etc.
|
||||
- Full track files: [album-name]-[track-number]-full.mp3
|
||||
|
||||
Example structure:
|
||||
```
|
||||
/public/audio/
|
||||
preview-1.mp3
|
||||
echoes-1-full.mp3
|
||||
echoes-2-full.mp3
|
||||
crimson-1-full.mp3
|
||||
...
|
||||
```
|
||||
|
||||
You can use any audio hosting service or place actual MP3 files here.
|
||||
36
tailwind.config.ts
Normal file
36
tailwind.config.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#e6f1ff',
|
||||
100: '#b3d9ff',
|
||||
200: '#80c1ff',
|
||||
300: '#4da8ff',
|
||||
400: '#1a90ff',
|
||||
500: '#0077e6',
|
||||
600: '#005db3',
|
||||
700: '#004380',
|
||||
800: '#00294d',
|
||||
900: '#000f1a',
|
||||
},
|
||||
accent: {
|
||||
orange: '#ff6b35',
|
||||
cyan: '#00d9ff',
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user