main: added inital version of music shop

Signed-off-by: nfel <nfilsaraee@gmail.com>
This commit is contained in:
nfel 2025-11-20 14:42:58 +03:30
commit 8a7842e263
Signed by: nfel
GPG Key ID: DCC0BF3F92B0D45F
25 changed files with 8820 additions and 0 deletions

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

39
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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">
&copy; {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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View 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
View 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
View 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
View 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
View 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"]
}