├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── actions ├── getActiveProductsWithPrices.ts ├── getLikedSongs.ts ├── getSongs.ts ├── getSongsByTitle.ts └── getSongsByUserId.ts ├── app ├── (site) │ ├── components │ │ └── PageContent.tsx │ ├── error.tsx │ ├── loading.tsx │ └── page.tsx ├── account │ ├── components │ │ └── AccountContent.tsx │ ├── error.tsx │ ├── loading.tsx │ └── page.tsx ├── api │ ├── create-checkout-session │ │ └── route.ts │ ├── create-portal-link │ │ └── route.ts │ └── webhooks │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── liked │ ├── components │ │ └── LikedContent.tsx │ ├── error.tsx │ ├── loading.tsx │ └── page.tsx └── search │ ├── components │ └── SearchContent.tsx │ ├── error.tsx │ ├── loading.tsx │ └── page.tsx ├── components ├── AuthModal.tsx ├── Box.tsx ├── Button.tsx ├── Header.tsx ├── Input.tsx ├── Library.tsx ├── LikeButton.tsx ├── ListItem.tsx ├── MediaItem.tsx ├── Modal.tsx ├── PlayButton.tsx ├── Player.tsx ├── PlayerContent.tsx ├── SearchInput.tsx ├── Sidebar.tsx ├── SidebarItem.tsx ├── Slider.tsx ├── SongItem.tsx ├── SubscribeModal.tsx └── UploadModal.tsx ├── hooks ├── useAuthModal.ts ├── useDebounce.ts ├── useGetSongById.ts ├── useLoadImage.ts ├── useLoadSongUrl.ts ├── useOnPlay.ts ├── usePlayer.ts ├── useSubscribeModal.ts ├── useUploadModal.ts └── useUser.tsx ├── libs ├── helpers.ts ├── stripe.ts ├── stripeClient.ts └── supabaseAdmin.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── providers ├── ModalProvider.tsx ├── SupabaseProvider.tsx ├── ToasterProvider.tsx └── UserProvider.tsx ├── public ├── images │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── liked.png ├── next.svg └── vercel.svg ├── tailwind.config.js ├── tsconfig.json ├── types.ts └── types_db.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # supabase password 38 | password.txt -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Full Stack Spotify Clone 2 | 3 | Explore this extensive tutorial to develop a complete Spotify clone from scratch using the latest in web development technologies. This project demonstrates the creation of a sleek, dynamic, and responsive UI, robust backend functionalities, and a seamless payment system using Stripe. 4 | 5 | ## Features 6 | 7 | - **Song Upload**: Upload and manage your music files with ease. 8 | 9 | - **Stripe Integration**: Enable premium subscriptions within the application using Stripe for payment processing. 10 | 11 | - **Database Handling**: Learn to set up a Supabase project, create database schemas, and manage data with PostgreSQL. 12 | 13 | - **Sleek User Interface**: Using Tailwind CSS, create a UI that closely resembles Spotify's sleek design. 14 | 15 | - **Responsiveness**: This application is fully responsive and compatible with all devices. 16 | 17 | - **Authentication**: Secure user registration and login processes with Supabase. 18 | 19 | - **GitHub Authentication Integration**: Enable secure login using Github authentication. 20 | 21 | - **File/Image Upload**: Upload files and images using Supabase storage. 22 | 23 | - **Form Validation**: Efficient client form validation and handling using react-hook-form. 24 | 25 | - **Error Handling**: Smooth server error handling with react-toast. 26 | 27 | - **Audio Playback**: Enable song playback within the application. 28 | 29 | - **Favorites System**: Users can mark songs as favorites. 30 | 31 | - **Playlists / Liked Songs System**: Create and manage song playlists. 32 | 33 | - **Advanced Player Component**: Explore the functionality of an advanced music player component. 34 | 35 | - **Stripe Recurring Payment Integration**: Manage recurring payments and subscriptions using Stripe. 36 | 37 | - **POST, GET, and DELETE Routes**: Learn to write and manage these crucial routes in route handlers (app/api). 38 | 39 | - **Data Fetching**: Master fetching data in server React components by directly accessing the database without API, like magic! 40 | 41 | - **Handling Relations**: Handle relations between Server and Child components in a real-time environment. 42 | 43 | - **Cancelling Stripe Subscriptions**: Learn how to cancel Stripe subscriptions within the application. 44 | 45 | ## Built With 46 | 47 | - Next.js 13.4 48 | - React 49 | - Tailwind CSS 50 | - Supabase 51 | - PostgreSQL 52 | - Stripe 53 | - react-hook-form 54 | - react-toast 55 | 56 | ## License 57 | 58 | This project is licensed under the terms of the MIT license. 59 | 60 | ## Contributions 61 | 62 | Contributions, issues, and feature requests are welcome! 63 | 64 | ## Get in Touch 65 | 66 | Email: tech@allanswebwork.info
67 | LinkedIn: [https://www.linkedin.com/in/allanhillman/](https://www.linkedin.com/in/allanhillman/)
68 | Website: [https://allanhillman.com](https://allanhillman.com/) 69 | -------------------------------------------------------------------------------- /actions/getActiveProductsWithPrices.ts: -------------------------------------------------------------------------------- 1 | import { ProductWithPrice } from '@/types'; 2 | import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; 3 | import { cookies } from 'next/headers'; 4 | 5 | export const getActiveProductsWithPrices = async (): Promise => { 6 | const supabase = createServerComponentClient({ 7 | cookies: cookies, 8 | }); 9 | 10 | const { data, error } = await supabase 11 | .from('products') 12 | .select('*, prices(*)') 13 | .eq('active', true) 14 | .eq('prices.active', true) 15 | .order('metadata->index') 16 | .order('unit_amount', { foreignTable: 'prices' }); 17 | 18 | if (error) { 19 | console.log(error); 20 | } 21 | 22 | return (data as any) || []; 23 | }; 24 | -------------------------------------------------------------------------------- /actions/getLikedSongs.ts: -------------------------------------------------------------------------------- 1 | import { Song } from '@/types'; 2 | import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; 3 | import { cookies } from 'next/headers'; 4 | 5 | export const getLikedSongs = async (): Promise => { 6 | const supabase = createServerComponentClient({ 7 | cookies: cookies, 8 | }); 9 | 10 | const { 11 | data: { session }, 12 | } = await supabase.auth.getSession(); 13 | 14 | const userId = session?.user?.id; 15 | if (!userId) { 16 | // console.log('User not authenticated or missing ID'); 17 | return []; 18 | } 19 | 20 | const { data, error } = await supabase 21 | .from('liked_songs') 22 | .select('*, songs(*)') 23 | .eq('user_id', userId) 24 | .order('created_at', { ascending: false }); 25 | 26 | if (error) { 27 | console.log(error); 28 | return []; 29 | } 30 | 31 | if (!data) { 32 | return []; 33 | } 34 | 35 | return data.map((item) => ({ 36 | //* Spread relation 37 | ...item.songs, 38 | })); 39 | }; 40 | -------------------------------------------------------------------------------- /actions/getSongs.ts: -------------------------------------------------------------------------------- 1 | import { Song } from "@/types"; 2 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; 3 | import { cookies } from "next/headers" 4 | 5 | //* Fetch song data 6 | export const getSongs = async (): Promise => { 7 | //* Create a Supabase client for server-side usage, passing cookies for session handling 8 | const supabase = createServerComponentClient({ 9 | cookies: cookies 10 | }); 11 | 12 | //* Make a request to fetch all records from the 'songs' table, ordered by creation date 13 | const {data, error} = await supabase 14 | .from('songs') 15 | .select('*') 16 | .order('created_at', {ascending: false}); 17 | 18 | if (error) { 19 | console.log(error) 20 | } 21 | 22 | return (data as any) || []; 23 | } -------------------------------------------------------------------------------- /actions/getSongsByTitle.ts: -------------------------------------------------------------------------------- 1 | import { Song } from "@/types"; 2 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; 3 | import { cookies } from "next/headers" 4 | import { getSongs } from "./getSongs"; 5 | 6 | export const getSongsByTitle = async (title: string): Promise => { 7 | const supabase = createServerComponentClient({ 8 | cookies: cookies 9 | }); 10 | 11 | if (!title) { 12 | const allSongs = await getSongs(); 13 | return allSongs 14 | } 15 | 16 | const {data, error} = await supabase 17 | .from('songs') 18 | .select('*') 19 | .ilike('title', `%${title}%`) 20 | .order('created_at', {ascending: false}); 21 | 22 | if (error) { 23 | console.log(error) 24 | } 25 | 26 | return (data as any) || []; 27 | } -------------------------------------------------------------------------------- /actions/getSongsByUserId.ts: -------------------------------------------------------------------------------- 1 | import { Song } from '@/types'; 2 | import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'; 3 | import { cookies } from 'next/headers'; 4 | 5 | export const getSongsByUserId = async (): Promise => { 6 | const supabase = createServerComponentClient({ 7 | cookies: cookies, 8 | }); 9 | 10 | const { data: sessionData, error: sessionError } = await supabase.auth.getSession(); 11 | 12 | if (sessionError) { 13 | console.log(sessionError.message); 14 | return []; 15 | } 16 | 17 | // Validate user ID before using in query 18 | const userId = sessionData.session?.user.id; 19 | if (!userId) { 20 | // console.log('User not authenticated or missing ID'); 21 | return []; 22 | } 23 | 24 | const { data, error } = await supabase 25 | .from('songs') 26 | .select('*') 27 | .eq('user_id', userId) 28 | .order('created_at', { ascending: false }); 29 | 30 | if (error) { 31 | console.log(error.message); 32 | return []; 33 | } 34 | return (data as any) || []; 35 | }; 36 | -------------------------------------------------------------------------------- /app/(site)/components/PageContent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { SongItem } from '@/components/SongItem'; 4 | import { useOnPlay } from '@/hooks/useOnPlay'; 5 | import { Song } from '@/types'; 6 | 7 | interface PageContentProps { 8 | songs: Song[]; 9 | } 10 | 11 | export const PageContent: React.FC = ({ songs }) => { 12 | const onPlay = useOnPlay(songs); 13 | 14 | if (songs.length === 0) { 15 | return
No songs available
; 16 | } 17 | 18 | return ( 19 |
32 | {songs.map((item) => ( 33 | onPlay(id)} data={item} /> 34 | ))} 35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /app/(site)/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box } from '@/components/Box'; 4 | 5 | const Error = () => { 6 | return ( 7 | 8 |
Something went wrong.
9 |
10 | ); 11 | }; 12 | 13 | export default Error; 14 | -------------------------------------------------------------------------------- /app/(site)/loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box } from '@/components/Box'; 4 | 5 | import { Triangle } from 'react-loader-spinner'; 6 | 7 | const Loading = () => { 8 | return ( 9 | 10 | 17 | 18 | ); 19 | }; 20 | 21 | export default Loading; 22 | -------------------------------------------------------------------------------- /app/(site)/page.tsx: -------------------------------------------------------------------------------- 1 | import { getSongs } from '@/actions/getSongs'; 2 | import { Header } from '@/components/Header'; 3 | import { ListItem } from '@/components/ListItem'; 4 | import { PageContent } from './components/PageContent'; 5 | 6 | export const revalidate = 0; 7 | 8 | export default async function Home() { 9 | const songs = await getSongs(); 10 | 11 | return ( 12 |
13 |
14 |
15 |

Welcome back

16 |
17 | 18 |
19 |
20 |
21 |
22 |
23 |

Newest Songs

24 |
25 |
26 | 27 | {/* {songs.map((song) =>
{song.title}
)} */} 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/account/components/AccountContent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | 5 | import { toast } from 'react-hot-toast'; 6 | 7 | import { useRouter } from 'next/navigation'; 8 | 9 | import { Button } from '@/components/Button'; 10 | 11 | import { useSubscribeModal } from '@/hooks/useSubscribeModal'; 12 | import { useUser } from '@/hooks/useUser'; 13 | 14 | import { postData } from '@/libs/helpers'; 15 | 16 | export const AccountContent = () => { 17 | const router = useRouter(); 18 | const subscribeModal = useSubscribeModal(); 19 | const { isLoading, user, subscription } = useUser(); 20 | 21 | const [loading, setLoading] = useState(false); 22 | 23 | useEffect(() => { 24 | if (!isLoading && !user) { 25 | router.replace('/'); 26 | } 27 | }, []); 28 | 29 | const redirectToCustomerPortal = async () => { 30 | setLoading(true); 31 | try { 32 | const { url, error } = await postData({ 33 | url: '/api/create-portal-link', 34 | }); 35 | window.location.assign(url); 36 | } catch (error) { 37 | if (error) { 38 | toast.error((error as Error).message); 39 | } 40 | setLoading(false); 41 | } 42 | }; 43 | return ( 44 |
45 | {!subscription && ( 46 |
47 |

No active plan.

48 | 51 |
52 | )} 53 | {subscription && ( 54 |
55 |

56 | You are currently on the {subscription?.prices?.products?.name} plan. 57 |

58 | 65 |
66 | )} 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /app/account/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box } from '@/components/Box'; 4 | 5 | const Error = () => { 6 | return ( 7 | 8 |
Something went wrong.
9 |
10 | ); 11 | }; 12 | 13 | export default Error; 14 | -------------------------------------------------------------------------------- /app/account/loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box } from '@/components/Box'; 4 | 5 | import { Triangle } from 'react-loader-spinner'; 6 | 7 | const Loading = () => { 8 | return ( 9 | 10 | 17 | 18 | ); 19 | }; 20 | 21 | export default Loading; 22 | -------------------------------------------------------------------------------- /app/account/page.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from '@/components/Header'; 2 | 3 | import { AccountContent } from './components/AccountContent'; 4 | 5 | const Account = () => { 6 | return ( 7 |
16 |
17 |
18 |

Account Settings

19 |
20 |
21 | 22 |
23 | ); 24 | }; 25 | 26 | export default Account; 27 | -------------------------------------------------------------------------------- /app/api/create-checkout-session/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; 2 | import { cookies } from 'next/headers'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | import { stripe } from '@/libs/stripe'; 6 | import { getURL } from '@/libs/helpers'; 7 | import { createOrRetrieveCustomer } from '@/libs/supabaseAdmin'; 8 | 9 | export async function POST(request: Request) { 10 | const { price, quantity = 1, metadata = {} } = await request.json(); 11 | 12 | try { 13 | const supabase = createRouteHandlerClient({ 14 | cookies, 15 | }); 16 | const { 17 | data: { user }, 18 | } = await supabase.auth.getUser(); 19 | 20 | // Validate user and user ID before proceeding 21 | if (!user || !user.id) { 22 | return new NextResponse('User not authenticated or invalid user ID', { status: 401 }); 23 | } 24 | 25 | const customer = await createOrRetrieveCustomer({ 26 | uuid: user.id, // No fallback to empty string 27 | email: user.email || '', 28 | }); 29 | 30 | const session = await stripe.checkout.sessions.create({ 31 | payment_method_types: ['card'], 32 | billing_address_collection: 'required', 33 | customer, 34 | line_items: [ 35 | { 36 | price: price.id, 37 | quantity, 38 | }, 39 | ], 40 | mode: 'subscription', 41 | allow_promotion_codes: true, 42 | subscription_data: { 43 | trial_from_plan: true, 44 | metadata, 45 | }, 46 | success_url: `${getURL()}account`, 47 | cancel_url: `${getURL()}`, 48 | }); 49 | return NextResponse.json({ sessionId: session.id }); 50 | } catch (error: any) { 51 | // console.log('Checkout session error:', error); 52 | return new NextResponse('Internal Error', { status: 500 }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/api/create-portal-link/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; 2 | 3 | import { cookies } from 'next/headers'; 4 | import { NextResponse } from 'next/server'; 5 | 6 | import { stripe } from '@/libs/stripe'; 7 | import { getURL } from '@/libs/helpers'; 8 | import { createOrRetrieveCustomer } from '@/libs/supabaseAdmin'; 9 | 10 | export async function POST() { 11 | try { 12 | const supabase = createRouteHandlerClient({ 13 | cookies, 14 | }); 15 | 16 | const { 17 | data: { user }, 18 | } = await supabase.auth.getUser(); 19 | 20 | if (!user) throw new Error('User not found'); 21 | 22 | // Enhanced validation before passing to createOrRetrieveCustomer 23 | if (!user.id) throw new Error('User ID is required'); 24 | 25 | const customer = await createOrRetrieveCustomer({ 26 | uuid: user.id, // No fallback to empty string 27 | email: user?.email || '', 28 | }); 29 | 30 | if (!customer) throw new Error('Customer not found'); 31 | 32 | const { url } = await stripe.billingPortal.sessions.create({ 33 | customer, 34 | return_url: `${getURL()}/account`, 35 | }); 36 | 37 | return NextResponse.json({ url }); 38 | } catch (error) { 39 | console.log(error); 40 | return new NextResponse('Internal Error', { status: 500 }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/api/webhooks/route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import he from 'he'; 3 | 4 | import { NextResponse } from 'next/server'; 5 | 6 | import { headers } from 'next/headers'; 7 | 8 | import { stripe } from '@/libs/stripe'; 9 | import { 10 | upsertProductRecord, 11 | upsertPriceRecord, 12 | manageSubscriptionStatusChange, 13 | } from '@/libs/supabaseAdmin'; 14 | 15 | const relevantEvents = new Set([ 16 | 'product.created', 17 | 'product.updated', 18 | 'price.created', 19 | 'price.updated', 20 | 'checkout.session.completed', 21 | 'customer.subscription.created', 22 | 'customer.subscription.updated', 23 | 'customer.subscription.deleted', 24 | ]); 25 | 26 | export async function POST(request: Request) { 27 | const body = await request.text(); 28 | const sig = headers().get('Stripe-Signature'); 29 | 30 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; 31 | let event: Stripe.Event; 32 | 33 | try { 34 | if (!sig || !webhookSecret) return; 35 | event = stripe.webhooks.constructEvent(body, sig, webhookSecret); 36 | } catch (error: any) { 37 | console.log('Error message:' + error.message); 38 | const sanitizedMessage = he.encode(error.message || 'Unknown error'); 39 | return new NextResponse(`Webhook Error: ${sanitizedMessage}`, { status: 400 }); 40 | } 41 | 42 | if (relevantEvents.has(event.type)) { 43 | try { 44 | switch (event.type) { 45 | case 'product.created': 46 | case 'product.updated': 47 | await upsertProductRecord(event.data.object as Stripe.Product); 48 | break; 49 | case 'price.created': 50 | case 'price.updated': 51 | await upsertPriceRecord(event.data.object as Stripe.Price); 52 | break; 53 | 54 | case 'customer.subscription-created': 55 | case 'customer.subscription-updated': 56 | case 'customer.subscription-deleted': 57 | const subscription = event.data.object as Stripe.Subscription; 58 | // Validate subscription properties before passing to manage function 59 | if (!subscription.id || !subscription.customer) { 60 | throw new Error('Missing subscription ID or customer ID in webhook event'); 61 | } 62 | await manageSubscriptionStatusChange( 63 | subscription.id, 64 | subscription.customer.toString(), 65 | event.type === 'customer.subscription-created' 66 | ); 67 | break; 68 | case 'checkout.session.completed': 69 | const checkoutSession = event.data.object as Stripe.Checkout.Session; 70 | if (checkoutSession.mode === 'subscription') { 71 | const subscriptionId = checkoutSession.subscription; 72 | const customerId = checkoutSession.customer; 73 | 74 | // Validate subscription and customer IDs before proceeding 75 | if ( 76 | !subscriptionId || 77 | subscriptionId === 'undefined' || 78 | !customerId || 79 | customerId === 'undefined' 80 | ) { 81 | throw new Error('Missing subscription ID or customer ID in checkout session'); 82 | } 83 | 84 | await manageSubscriptionStatusChange( 85 | subscriptionId as string, 86 | customerId as string, 87 | true 88 | ); 89 | } 90 | break; 91 | default: 92 | throw new Error('Unhandled relevant event'); 93 | } 94 | } catch (error) { 95 | console.log(error); 96 | return new NextResponse('Webhook Error:', { status: 400 }); 97 | } 98 | } 99 | 100 | return NextResponse.json({ received: true }); 101 | } 102 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrwebwork/spotify/d8a03103c32d48c157374afa0e8be8f23f9e2ac1/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, body, :root{ 6 | height: 100%; 7 | background-color: black; 8 | color-scheme: dark; 9 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Sidebar } from '@/components/Sidebar'; 2 | 3 | import './globals.css'; 4 | 5 | import { Analytics } from '@vercel/analytics/react'; 6 | 7 | import { Figtree } from 'next/font/google'; 8 | 9 | import { SupabaseProvider } from '@/providers/SupabaseProvider'; 10 | import { UserProvider } from '@/providers/UserProvider'; 11 | import { ModalProvider } from '@/providers/ModalProvider'; 12 | import { ToasterProvider } from '@/providers/ToasterProvider'; 13 | 14 | import { getSongsByUserId } from '@/actions/getSongsByUserId'; 15 | import { Player } from '@/components/Player'; 16 | import { getActiveProductsWithPrices } from '@/actions/getActiveProductsWithPrices'; 17 | 18 | const font = Figtree({ subsets: ['latin'] }); 19 | 20 | //* Describe the web app 21 | export const metadata = { 22 | title: 'Spotify Clone', 23 | description: 'Listen to music!', 24 | }; 25 | 26 | export const revalidate = 0; 27 | 28 | //* Main layout component for the app 29 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 30 | const userSongs = await getSongsByUserId(); 31 | const products = await getActiveProductsWithPrices(); 32 | 33 | //* Providers & Components 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {children} 48 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/liked/components/LikedContent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | import { useRouter } from 'next/navigation'; 6 | 7 | import { Song } from '@/types'; 8 | import { useUser } from '@/hooks/useUser'; 9 | import { MediaItem } from '@/components/MediaItem'; 10 | import { LikeButton } from '@/components/LikeButton'; 11 | import { useOnPlay } from '@/hooks/useOnPlay'; 12 | 13 | interface LikedContentProps { 14 | songs: Song[]; 15 | } 16 | 17 | export const LikedContent: React.FC = ({ songs }) => { 18 | const router = useRouter(); 19 | const { isLoading, user } = useUser(); 20 | const onPlay = useOnPlay(songs); 21 | 22 | useEffect(() => { 23 | if (!isLoading && !user) { 24 | router.replace('/'); 25 | } 26 | }, [isLoading, user, router]); 27 | 28 | if (songs.length === 0) { 29 | return ( 30 |
40 | No Liked songs. 41 |
42 | ); 43 | } 44 | 45 | return ( 46 |
47 | {songs.map((song) => ( 48 |
49 |
50 | onPlay(id)} data={song} /> 51 |
52 | 53 |
54 | ))} 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /app/liked/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box } from '@/components/Box'; 4 | 5 | const Error = () => { 6 | return ( 7 | 8 |
Something went wrong.
9 |
10 | ); 11 | }; 12 | 13 | export default Error; 14 | -------------------------------------------------------------------------------- /app/liked/loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box } from '@/components/Box'; 4 | 5 | import { Triangle } from 'react-loader-spinner'; 6 | 7 | const Loading = () => { 8 | return ( 9 | 10 | 17 | 18 | ); 19 | }; 20 | 21 | export default Loading; 22 | -------------------------------------------------------------------------------- /app/liked/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | import { getLikedSongs } from '@/actions/getLikedSongs'; 4 | import { Header } from '@/components/Header'; 5 | import { LikedContent } from './components/LikedContent'; 6 | 7 | export const revalidate = 0; 8 | 9 | const Liked = async () => { 10 | const songs = await getLikedSongs(); 11 | 12 | return ( 13 |
23 |
24 |
25 |
34 |
43 | Playlist 44 |
45 |
54 |

Playlist

55 |

64 | Liked Songs 65 |

66 |
67 |
68 |
69 |
70 | 71 |
72 | ); 73 | }; 74 | 75 | export default Liked; 76 | -------------------------------------------------------------------------------- /app/search/components/SearchContent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useOnPlay } from '@/hooks/useOnPlay'; 4 | 5 | import { LikeButton } from '@/components/LikeButton'; 6 | import { MediaItem } from '@/components/MediaItem'; 7 | 8 | import { Song } from '@/types'; 9 | 10 | interface SearchContentProps { 11 | songs: Song[]; 12 | } 13 | 14 | export const SearchContent: React.FC = ({ songs }) => { 15 | //* Pass all the songs from the playlist 16 | const onPlay = useOnPlay(songs); 17 | if (songs.length === 0) { 18 | return ( 19 |
29 | ); 30 | } 31 | return ( 32 |
33 | {songs.map((song) => ( 34 |
35 |
36 | onPlay(id)} data={song} /> 37 |
38 | 39 |
40 | ))} 41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /app/search/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box } from '@/components/Box'; 4 | 5 | const Error = () => { 6 | return ( 7 | 8 |
Something went wrong.
9 |
10 | ); 11 | }; 12 | 13 | export default Error; 14 | -------------------------------------------------------------------------------- /app/search/loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Box } from '@/components/Box'; 4 | 5 | import { Triangle } from 'react-loader-spinner'; 6 | 7 | const Loading = () => { 8 | return ( 9 | 10 | 17 | 18 | ); 19 | }; 20 | 21 | export default Loading; 22 | -------------------------------------------------------------------------------- /app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { getSongsByTitle } from '@/actions/getSongsByTitle'; 2 | 3 | import { Header } from '@/components/Header'; 4 | import { SearchInput } from '@/components/SearchInput'; 5 | 6 | import { SearchContent } from './components/SearchContent'; 7 | 8 | export const revalidate = 0; 9 | 10 | interface SearchProps { 11 | searchParams: { 12 | title: string; 13 | }; 14 | } 15 | 16 | const Search = async ({ searchParams }: SearchProps) => { 17 | const songs = await getSongsByTitle(searchParams.title); 18 | 19 | return ( 20 |
30 |
31 |
32 |

Search

33 | 34 |
35 |
36 | 37 |
38 | ); 39 | }; 40 | 41 | export default Search; 42 | -------------------------------------------------------------------------------- /components/AuthModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | import { useSessionContext, useSupabaseClient } from '@supabase/auth-helpers-react'; 6 | import { Auth } from '@supabase/auth-ui-react'; 7 | import { ThemeSupa } from '@supabase/auth-ui-shared'; 8 | 9 | import { Modal } from './Modal'; 10 | import { useRouter } from 'next/navigation'; 11 | 12 | import { useAuthModal } from '@/hooks/useAuthModal'; 13 | 14 | export const AuthModal = () => { 15 | //* Initializes Supabase client, Next.js router and session context. 16 | const supabaseClient = useSupabaseClient(); 17 | const router = useRouter(); 18 | const { session } = useSessionContext(); 19 | const { onClose, isOpen } = useAuthModal(); 20 | 21 | //* Effect hook for handling session changes. 22 | useEffect(() => { 23 | if (session) { 24 | router.refresh(); 25 | onClose(); 26 | } 27 | }, [session, router, onClose]); 28 | 29 | //* Handler for modal open state changes. 30 | const onChange = (open: boolean) => { 31 | if (!open) { 32 | onClose(); 33 | } 34 | }; 35 | 36 | return ( 37 | 43 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /components/Box.tsx: -------------------------------------------------------------------------------- 1 | //* Import tailwind-merge for combining Tailwind CSS classes. 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | //* Define the props interface for the Box component. 5 | interface BoxProps { 6 | children: React.ReactNode; 7 | className?: string; 8 | } 9 | 10 | export const Box: React.FC = ({ children, className }) => { 11 | return ( 12 |
23 | {children} 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | //* Define the props interface for the Button component. 5 | //* It extends button HTML attributes, enabling all native button attributes. 6 | interface ButtonProps extends React.ButtonHTMLAttributes {} 7 | 8 | //* Define the Button component using forwardRef to allow ref pass-through. 9 | export const Button = forwardRef( 10 | ({ className, children, disabled, type = 'button', ...props }, ref) => { 11 | return ( 12 | //* Button with merged tailwind classes and optional className. 13 | 39 | ); 40 | } 41 | ); 42 | 43 | //* Set displayName for the Button component. 44 | Button.displayName = 'Button'; 45 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from './Button'; 4 | 5 | import { usePlayer } from '@/hooks/usePlayer'; 6 | import { useUser } from '@/hooks/useUser'; 7 | import { useAuthModal } from '@/hooks/useAuthModal'; 8 | 9 | import { useSupabaseClient } from '@supabase/auth-helpers-react'; 10 | 11 | import { FaUserAlt } from 'react-icons/fa'; 12 | import { RxCaretLeft } from 'react-icons/rx'; 13 | import { RxCaretRight } from 'react-icons/rx'; 14 | import { HiHome } from 'react-icons/hi'; 15 | import { BiSearch } from 'react-icons/bi'; 16 | 17 | import { useRouter } from 'next/navigation'; 18 | 19 | import { twMerge } from 'tailwind-merge'; 20 | 21 | import { toast } from 'react-hot-toast'; 22 | 23 | //* Define the props interface for the Header component. 24 | interface HeaderProps { 25 | children: React.ReactNode; 26 | className?: string; 27 | } 28 | 29 | //* Define the Header functional component. 30 | export const Header: React.FC = ({ children, className }) => { 31 | //* Use custom hooks and utilities. 32 | const authModal = useAuthModal(); 33 | const router = useRouter(); 34 | const supabaseClient = useSupabaseClient(); 35 | const { user } = useUser(); 36 | const player = usePlayer(); 37 | 38 | //* Define logout handler 39 | const handleLogout = async () => { 40 | const { error } = await supabaseClient.auth.signOut(); 41 | player.reset(); 42 | router.refresh(); 43 | 44 | if (error) { 45 | toast.error(error.message); 46 | } else { 47 | toast.success('Logged out!'); 48 | } 49 | }; 50 | 51 | //* Header component with navigation and login/logout. 52 | return ( 53 |
64 |
73 |
74 | 80 | 86 |
87 |
88 | 94 | 100 |
101 |
102 | {user ? ( 103 |
110 | 113 | 116 |
117 | ) : ( 118 | <> 119 |
120 | 126 |
127 |
128 | 131 |
132 | 133 | )} 134 |
135 |
136 | {children} 137 |
138 | ); 139 | }; 140 | -------------------------------------------------------------------------------- /components/Input.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | //* Define an interface for the Input component props 5 | interface InputProps extends React.InputHTMLAttributes {} 6 | //* Define the Input functional component 7 | export const Input = forwardRef( 8 | ({ className, type, disabled, ...props }, ref) => { 9 | return ( 10 | //* Render an input field 11 | 39 | ); 40 | } 41 | ); 42 | 43 | Input.displayName = 'Input'; 44 | -------------------------------------------------------------------------------- /components/Library.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { TbPlaylist } from 'react-icons/tb'; 4 | import { AiOutlinePlus } from 'react-icons/ai'; 5 | 6 | import { useSubscribeModal } from '@/hooks/useSubscribeModal'; 7 | import { useOnPlay } from '@/hooks/useOnPlay'; 8 | import { useAuthModal } from '@/hooks/useAuthModal'; 9 | import { useUser } from '@/hooks/useUser'; 10 | import { useUploadModal } from '@/hooks/useUploadModal'; 11 | 12 | import { Song } from '@/types'; 13 | import { MediaItem } from './MediaItem'; 14 | 15 | interface LibraryProps { 16 | songs: Song[]; 17 | } 18 | 19 | export const Library: React.FC = ({ songs }) => { 20 | //* Hooks initialization 21 | const subscribeModal = useSubscribeModal(); 22 | const authModal = useAuthModal(); 23 | const uploadModal = useUploadModal(); 24 | const { user, subscription } = useUser(); 25 | 26 | //* Pass all the songs in the playlist 27 | const onPlay = useOnPlay(songs); 28 | 29 | const onClick = () => { 30 | if (!user) { 31 | return authModal.onOpen(); 32 | } 33 | 34 | if (!subscription) { 35 | return subscribeModal.onOpen(); 36 | } 37 | 38 | //* Open upload modal 39 | return uploadModal.onOpen(); 40 | }; 41 | 42 | return ( 43 |
44 |
45 |
46 | 47 |

Your Library

48 |
49 | 54 |
55 |
56 | {songs.map((item) => ( 57 | onPlay(id)} key={item.id} data={item} /> 58 | ))} 59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /components/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | 5 | import { useRouter } from 'next/navigation'; 6 | 7 | import { useAuthModal } from '@/hooks/useAuthModal'; 8 | import { useUser } from '@/hooks/useUser'; 9 | 10 | import { useSessionContext } from '@supabase/auth-helpers-react'; 11 | import { AiFillHeart, AiOutlineHeart } from 'react-icons/ai'; 12 | import { toast } from 'react-hot-toast'; 13 | 14 | interface LikeButtonProps { 15 | songId: string; 16 | } 17 | 18 | export const LikeButton: React.FC = ({ songId }) => { 19 | const router = useRouter(); 20 | const { supabaseClient } = useSessionContext(); 21 | 22 | const authModal = useAuthModal(); 23 | const { user } = useUser(); 24 | 25 | const [isLiked, setIsLiked] = useState(false); 26 | 27 | //* Check if song is liked or not 28 | useEffect(() => { 29 | if (!user?.id) { 30 | return; 31 | } 32 | 33 | // Make sure we have valid IDs 34 | if (!songId || songId === 'undefined') { 35 | console.error('Invalid song ID'); 36 | return; 37 | } 38 | 39 | const fetchData = async () => { 40 | //* Find song in liked_songs table 41 | const { data, error } = await supabaseClient 42 | .from('liked_songs') 43 | .select('*') 44 | .eq('user_id', user.id) 45 | .eq('song_id', songId) 46 | .single(); 47 | 48 | if (!error && data) { 49 | setIsLiked(true); 50 | } 51 | }; 52 | 53 | fetchData(); 54 | }, [songId, supabaseClient, user?.id]); 55 | 56 | //* Dynamically render icon if the song is liked or not 57 | const Icon = isLiked ? AiFillHeart : AiOutlineHeart; 58 | 59 | const handleLike = async () => { 60 | if (!user) { 61 | return authModal.onOpen(); 62 | } 63 | 64 | // Validate user ID and song ID 65 | if (!user.id || user.id === 'undefined') { 66 | toast.error('User ID is invalid'); 67 | return; 68 | } 69 | 70 | if (!songId || songId === 'undefined') { 71 | toast.error('Song ID is invalid'); 72 | return; 73 | } 74 | 75 | if (isLiked) { 76 | const { error } = await supabaseClient 77 | .from('liked_songs') 78 | .delete() 79 | .eq('user_id', user.id) 80 | .eq('song_id', songId); 81 | 82 | if (error) { 83 | toast.error(error.message); 84 | } else { 85 | setIsLiked(false); 86 | } 87 | } else { 88 | const { error } = await supabaseClient.from('liked_songs').insert({ 89 | song_id: songId, 90 | user_id: user.id, 91 | }); 92 | 93 | if (error) { 94 | toast.error(error.message); 95 | } else { 96 | setIsLiked(true); 97 | toast.success('Liked!'); 98 | } 99 | } 100 | 101 | router.refresh(); 102 | }; 103 | 104 | return ( 105 | 109 | ); 110 | }; 111 | -------------------------------------------------------------------------------- /components/ListItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | import Image from 'next/image'; 5 | 6 | import { FaPlay } from 'react-icons/fa'; 7 | 8 | //* Define ListItemProps interface 9 | interface ListItemProps { 10 | image: string; 11 | name: string; 12 | href: string; 13 | } 14 | 15 | //* Define ListItem component 16 | export const ListItem: React.FC = ({ image, name, href }) => { 17 | const router = useRouter(); 18 | 19 | const onClick = () => { 20 | router.push(href); 21 | }; 22 | 23 | return ( 24 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /components/MediaItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Image from 'next/image'; 4 | 5 | import { useLoadImage } from '@/hooks/useLoadImage'; 6 | 7 | import { Song } from '@/types'; 8 | 9 | import { usePlayer } from '@/hooks/usePlayer'; 10 | 11 | interface MediaItemProps { 12 | data: Song; 13 | onClick?: (id: string) => void; 14 | } 15 | 16 | export const MediaItem: React.FC = ({ data, onClick }) => { 17 | const player = usePlayer(); 18 | const imageUrl = useLoadImage(data); 19 | const handleClick = () => { 20 | if (onClick) { 21 | return onClick(data.id); 22 | } 23 | 24 | return player.setId(data.id); 25 | }; 26 | return ( 27 |
40 |
49 | Media Item 55 |
56 |
64 |

{data.title}

65 |

{data.author}

66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import * as Dialog from '@radix-ui/react-dialog'; 2 | import { IoMdClose } from 'react-icons/io'; 3 | 4 | //* Declaring the type for the Modal component's properties 5 | interface ModalProps { 6 | isOpen: boolean; 7 | onChange: (open: boolean) => void; 8 | title: string; 9 | description: string; 10 | children: React.ReactNode; 11 | } 12 | 13 | //* Modal component using React Function Component with ModalProps 14 | export const Modal: React.FC = ({ isOpen, onChange, title, description, children }) => { 15 | //* The Modal component uses Dialog.Root as the parent container 16 | return ( 17 | 18 | 19 | 27 | 50 | 58 | {title} 59 | 60 | 68 | {description} 69 | 70 |
{children}
71 | 72 | 91 | 92 |
93 |
94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /components/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import { FaPlay } from 'react-icons/fa'; 2 | 3 | export const PlayButton = () => { 4 | return ( 5 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /components/Player.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useGetSongById } from '@/hooks/useGetSongById'; 4 | import { useLoadSongUrl } from '@/hooks/useLoadSongUrl'; 5 | import { usePlayer } from '@/hooks/usePlayer'; 6 | 7 | import { PlayerContent } from './PlayerContent'; 8 | 9 | export const Player = () => { 10 | const player = usePlayer(); 11 | const { song } = useGetSongById(player.activeId); 12 | 13 | const songUrl = useLoadSongUrl(song!); 14 | 15 | //* Don't load player if you dont have the song, url or id 16 | if (!song || !songUrl || !player.activeId) { 17 | return null; 18 | } 19 | 20 | return ( 21 |
32 | {/* //! Using the `key` attribute on this component to destory it and re-load to the new songUrl */} 33 | 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /components/PlayerContent.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useState } from 'react'; 4 | 5 | import { Song } from '@/types'; 6 | 7 | import { usePlayer } from '@/hooks/usePlayer'; 8 | 9 | import { BsPauseFill, BsPlayFill } from 'react-icons/bs'; 10 | import { AiFillBackward, AiFillStepForward } from 'react-icons/ai'; 11 | import { HiSpeakerXMark, HiSpeakerWave } from 'react-icons/hi2'; 12 | 13 | import { MediaItem } from './MediaItem'; 14 | import { LikeButton } from './LikeButton'; 15 | import { Slider } from './Slider'; 16 | import useSound from 'use-sound'; 17 | 18 | interface PlayerContentProps { 19 | song: Song; 20 | songUrl: string; 21 | } 22 | 23 | export const PlayerContent: React.FC = ({ song, songUrl }) => { 24 | const player = usePlayer(); 25 | const [volume, setVolume] = useState(1); 26 | const [isPlaying, setIsPlaying] = useState(false); 27 | 28 | const Icon = isPlaying ? BsPauseFill : BsPlayFill; 29 | const VolumeIcon = volume === 0 ? HiSpeakerXMark : HiSpeakerWave; 30 | 31 | //* Play the next song in the playlist 32 | const onPlayNextSong = () => { 33 | if (player.ids.length === 0) { 34 | return; 35 | } 36 | 37 | const currentIndex = player.ids.findIndex((id) => id === player.activeId); 38 | //* Check if there is a next song to play 39 | const nextSong = player.ids[currentIndex + 1]; 40 | //* If song is last in array, reset playlist 41 | if (!nextSong) { 42 | return player.setId(player.ids[0]); 43 | } 44 | 45 | player.setId(nextSong); 46 | }; 47 | 48 | //* Play the previous song in the playlist 49 | const onPlayPreviousSong = () => { 50 | if (player.ids.length === 0) { 51 | return; 52 | } 53 | 54 | const currentIndex = player.ids.findIndex((id) => id === player.activeId); 55 | //* Check the song BEFORE the one currently playing 56 | const previousSong = player.ids[currentIndex - 1]; 57 | 58 | //? What if the song we are currently playing is FIRST in the array & we click on play previous song 59 | //! Don't load the same song 60 | //* Load the LAST song in the array, go backwards 61 | if (!previousSong) { 62 | return player.setId(player.ids[player.ids.length - 1]); 63 | } 64 | 65 | player.setId(previousSong); 66 | }; 67 | 68 | const [play, { pause, sound }] = useSound(songUrl, { 69 | volume: volume, 70 | //* Play song 71 | onplay: () => setIsPlaying(true), 72 | //* End song then immediately play next song 73 | onend: () => { 74 | setIsPlaying(false); 75 | onPlayNextSong(); 76 | }, 77 | onpause: () => setIsPlaying(false), 78 | format: ['mp3'], 79 | }); 80 | 81 | //* Automatcially play the song when the player component loads 82 | useEffect(() => { 83 | sound?.play(); 84 | return () => { 85 | sound?.unload(); 86 | }; 87 | }, [sound]); 88 | 89 | const handlePlay = () => { 90 | if (!isPlaying) { 91 | play(); 92 | } else { 93 | pause(); 94 | } 95 | }; 96 | 97 | const toggleMute = () => { 98 | if (volume === 0) { 99 | setVolume(1); 100 | } else { 101 | setVolume(0); 102 | } 103 | }; 104 | 105 | return ( 106 |
114 |
121 |
128 | 129 | 130 |
131 |
132 | 133 |
143 |
157 | 158 |
159 |
160 | 161 | {/* //* Desktop View for the playbutton */} 162 |
174 | 184 |
198 | 199 |
200 | 210 |
211 | 212 |
213 |
214 | 221 | setVolume(value)} /> 222 |
223 |
224 |
225 | ); 226 | }; 227 | -------------------------------------------------------------------------------- /components/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import qs from 'query-string'; 4 | 5 | import useDebounce from '@/hooks/useDebounce'; 6 | 7 | import { useRouter } from 'next/navigation'; 8 | 9 | import { useEffect, useState } from 'react'; 10 | import { Input } from './Input'; 11 | 12 | export const SearchInput = () => { 13 | const router = useRouter(); 14 | const [value, setValue] = useState(''); 15 | const debouncedValue = useDebounce(value, 500); 16 | 17 | useEffect(() => { 18 | const query = { 19 | title: debouncedValue, 20 | }; 21 | 22 | const url = qs.stringifyUrl({ 23 | url: '/search', 24 | query: query, 25 | }); 26 | router.push(url); 27 | }, [debouncedValue, router]); 28 | 29 | return ( 30 | setValue(e.target.value)} 34 | /> 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | import { usePathname } from 'next/navigation'; 6 | 7 | import { usePlayer } from '@/hooks/usePlayer'; 8 | 9 | import { useMemo } from 'react'; 10 | import { HiHome } from 'react-icons/hi'; 11 | import { BiSearch } from 'react-icons/bi'; 12 | 13 | import { Box } from './Box'; 14 | import { SidebarItem } from './SidebarItem'; 15 | import { Library } from './Library'; 16 | 17 | import { Song } from '@/types'; 18 | 19 | //* Declaring the type for the Sidebar component's properties 20 | interface SidebarProps { 21 | children: React.ReactNode; 22 | songs: Song[]; 23 | } 24 | 25 | //* Sidebar component using React Function Component with SidebarProps 26 | export const Sidebar: React.FC = ({ children, songs }) => { 27 | //* Using Next.js usePathname hook to get the current URL path 28 | const pathname = usePathname(); 29 | 30 | const player = usePlayer(); 31 | 32 | //* Defining sidebar routes with useMemo hook for performance optimization 33 | const routes = useMemo( 34 | () => [ 35 | { 36 | icon: HiHome, 37 | label: 'Home', 38 | active: pathname !== '/search', 39 | href: '/', 40 | }, 41 | { 42 | icon: BiSearch, 43 | label: 'Search', 44 | active: pathname === 'search', 45 | href: '/search', 46 | }, 47 | ], 48 | [] 49 | ); 50 | 51 | return ( 52 | //* Make class dynamic depending on open player vs closed player 53 |
62 |
63 | 64 |
65 | {routes.map((item) => ( 66 | 67 | ))} 68 |
69 |
70 | 71 | 72 | 73 |
74 |
{children}
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /components/SidebarItem.tsx: -------------------------------------------------------------------------------- 1 | import { IconType } from 'react-icons'; 2 | import Link from 'next/link'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | //* Declaring the type for the SidebarItem component's properties 6 | interface SidebarItemProps { 7 | icon: IconType; 8 | label: string; 9 | active?: boolean; 10 | href: string; 11 | } 12 | 13 | //* SidebarItem component using React Function Component with SidebarItemProps 14 | export const SidebarItem: React.FC = ({ icon: Icon, label, active, href }) => { 15 | //* The component includes a Link component, which is used for navigation 16 | return ( 17 | 38 | 39 |

{label}

40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /components/Slider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as RadixSlider from '@radix-ui/react-slider'; 4 | 5 | interface SliderProps { 6 | value?: number; 7 | onChange?: (value: number) => void; 8 | } 9 | 10 | export const Slider: React.FC = ({ value = 1, onChange }) => { 11 | const handleChange = (newValue: number[]) => { 12 | onChange?.(newValue[0]); 13 | }; 14 | return ( 15 | 32 | 41 | 49 | 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /components/SongItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Song } from '@/types'; 4 | 5 | import { PlayButton } from './PlayButton'; 6 | 7 | import Image from 'next/image'; 8 | 9 | import { useLoadImage } from '@/hooks/useLoadImage'; 10 | 11 | interface SongItemProps { 12 | data: Song; 13 | onClick: (id: string) => void; 14 | } 15 | 16 | export const SongItem: React.FC = ({ data, onClick }) => { 17 | const imagePath = useLoadImage(data); 18 | 19 | return ( 20 |
onClick(data.id)} 22 | className=" 23 | relative 24 | group 25 | flex 26 | flex-col 27 | items-center 28 | justify-center 29 | overflow-hidden 30 | gap-x-4 31 | bg-neutral-400/5 32 | cursor-pointer 33 | hover:bg-neutral-400/10 34 | transition 35 | p-3 36 | " 37 | > 38 |
48 | Image 55 |
56 |
57 |

{data.title}

58 |

By {data.author}

59 |
60 |
61 | 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /components/SubscribeModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | 5 | import { toast } from 'react-hot-toast'; 6 | 7 | import { postData } from '@/libs/helpers'; 8 | import { getStripe } from '@/libs/stripeClient'; 9 | 10 | import { useUser } from '@/hooks/useUser'; 11 | 12 | import { Price, ProductWithPrice } from '@/types'; 13 | 14 | import { Modal } from './Modal'; 15 | import { Button } from './Button'; 16 | import { useSubscribeModal } from '@/hooks/useSubscribeModal'; 17 | 18 | interface subscribeModalProps { 19 | products: ProductWithPrice[]; 20 | } 21 | 22 | const formatPrice = (price: Price) => { 23 | return new Intl.NumberFormat('en-US', { 24 | style: 'currency', 25 | currency: price.currency, 26 | minimumFractionDigits: 0, 27 | }).format((price?.unit_amount || 0) / 100); 28 | }; 29 | 30 | const SubscribeModal: React.FC = ({ products }) => { 31 | const subscribeModal = useSubscribeModal(); 32 | const [priceIdLoading, setPriceIdLoading] = useState(); 33 | const { user, isLoading, subscription } = useUser(); 34 | 35 | const onChange = (open: boolean) => { 36 | if (!open) { 37 | subscribeModal.onClose(); 38 | } 39 | }; 40 | 41 | const handleCheckout = async (price: Price) => { 42 | setPriceIdLoading(price.id); 43 | 44 | if (!user) { 45 | setPriceIdLoading(undefined); 46 | return toast.error('Must be logged in to subscribe'); 47 | } 48 | 49 | if (subscription) { 50 | setPriceIdLoading(undefined); 51 | return toast('You are already subscribed'); 52 | } 53 | 54 | try { 55 | const { sessionId } = await postData({ 56 | url: '/api/create-checkout-session', 57 | data: { price }, 58 | }); 59 | 60 | const stripe = await getStripe(); 61 | 62 | stripe?.redirectToCheckout({ sessionId }); 63 | } catch (error) { 64 | toast.error((error as Error)?.message); 65 | } finally { 66 | setPriceIdLoading(undefined); 67 | } 68 | }; 69 | 70 | let content =
No products avaliable
; 71 | 72 | if (products.length) { 73 | content = ( 74 |
75 | {products.map((product) => { 76 | if (!product.prices?.length) { 77 | return
No prices avaliable
; 78 | } 79 | 80 | return product.prices.map((price) => ( 81 | 87 | )); 88 | })} 89 |
90 | ); 91 | } 92 | 93 | if (subscription) { 94 | content =
Already subscribed!
; 95 | } 96 | 97 | return ( 98 | 104 | {content} 105 | 106 | ); 107 | }; 108 | 109 | export default SubscribeModal; 110 | -------------------------------------------------------------------------------- /components/UploadModal.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import uniqid from 'uniqid'; 4 | 5 | import { useState } from 'react'; 6 | import { FieldValues, SubmitHandler, useForm } from 'react-hook-form'; 7 | import { toast } from 'react-hot-toast'; 8 | import { useSupabaseClient } from '@supabase/auth-helpers-react'; 9 | import { useRouter } from 'next/navigation'; 10 | 11 | import { useUploadModal } from '@/hooks/useUploadModal'; 12 | import { useUser } from '@/hooks/useUser'; 13 | 14 | import { Modal } from './Modal'; 15 | import { Input } from './Input'; 16 | import { Button } from './Button'; 17 | 18 | export const UploadModal = () => { 19 | //* Initialising state and hooks 20 | const [isLoading, setIsLoading] = useState(false); 21 | const uploadModal = useUploadModal(); 22 | const { user } = useUser(); 23 | const supabaseClient = useSupabaseClient(); 24 | const router = useRouter(); 25 | 26 | //* Using null for the files 27 | //* Initialise react-hook-form methods and set default form values 28 | const { register, handleSubmit, reset } = useForm({ 29 | defaultValues: { 30 | author: '', 31 | title: '', 32 | song: null, 33 | image: null, 34 | }, 35 | }); 36 | 37 | const onChange = (open: boolean) => { 38 | if (!open) { 39 | reset(); 40 | uploadModal.onClose(); 41 | } 42 | }; 43 | 44 | const onSubmit: SubmitHandler = async (values) => { 45 | //* Upload to supabase 46 | try { 47 | setIsLoading(true); 48 | 49 | const imageFile = values.image?.[0]; 50 | const songFile = values.song?.[0]; 51 | 52 | if (!imageFile || !songFile || !user) { 53 | toast.error('Missing fields'); 54 | return; 55 | } 56 | 57 | const uniqueID = uniqid(); 58 | 59 | //* Upload song to Supabase storage 60 | const { data: songData, error: songError } = await supabaseClient.storage 61 | .from('songs') 62 | .upload(`song-${values.title}-${uniqueID}`, songFile, { 63 | cacheControl: '3600', 64 | upsert: false, 65 | }); 66 | 67 | if (songError) { 68 | setIsLoading(false); 69 | return toast.error('Failed song upload.'); 70 | } 71 | 72 | //* Upload image to Supabase storage 73 | const { data: imageData, error: imageError } = await supabaseClient.storage 74 | .from('images') 75 | .upload(`image-${values.title}-${uniqueID}`, imageFile, { 76 | cacheControl: '3600', 77 | upsert: false, 78 | }); 79 | 80 | if (imageError) { 81 | setIsLoading(false); 82 | return toast.error('Failed image upload.'); 83 | } 84 | 85 | // Validate user ID before database operation 86 | if (!user.id || user.id === 'undefined') { 87 | setIsLoading(false); 88 | return toast.error('Invalid user ID'); 89 | } 90 | 91 | //* Insert new song record in the Supabase 'songs' table 92 | const { error: supabaseError } = await supabaseClient.from('songs').insert({ 93 | user_id: user.id, 94 | title: values.title, 95 | author: values.author, 96 | image_path: imageData.path, 97 | song_path: songData.path, 98 | }); 99 | 100 | if (supabaseError) { 101 | setIsLoading(false); 102 | return toast.error(supabaseError.message); 103 | } 104 | 105 | router.refresh(); 106 | setIsLoading(false); 107 | toast.success('Song is created!'); 108 | reset(); 109 | uploadModal.onClose(); 110 | } catch (error) { 111 | toast.error('Something went wrong'); 112 | } finally { 113 | setIsLoading(false); 114 | } 115 | }; 116 | 117 | //* Render the Modal component with a form to upload a new song 118 | return ( 119 | 125 |
126 | 132 | 138 |
139 |
Select a song file
140 | 147 |
148 |
149 |
Select an image
150 | 157 |
158 | 161 |
162 |
163 | ); 164 | }; 165 | -------------------------------------------------------------------------------- /hooks/useAuthModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface AuthModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | export const useAuthModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | //* Custom hook to implement a debounce feature for any value type. 4 | export default function useDebounce(value: T, delay?: number): T { 5 | const [debounceValue, setDebounceValue] = useState(value); 6 | 7 | //* Run the effect after every render when the 'value' or 'delay' changes. 8 | useEffect(() => { 9 | const timer = setTimeout(() => { 10 | setDebounceValue(value); 11 | }, delay || 500); 12 | 13 | //* This will prevent setting state if the component unmounts before the delay has passed. 14 | return () => { 15 | clearTimeout(timer); 16 | }; 17 | }, [value, delay]); 18 | 19 | //* Returning the debounced value. 20 | return debounceValue; 21 | } 22 | -------------------------------------------------------------------------------- /hooks/useGetSongById.ts: -------------------------------------------------------------------------------- 1 | import { Song } from '@/types'; 2 | 3 | import { useSessionContext } from '@supabase/auth-helpers-react'; 4 | 5 | import { useEffect, useMemo, useState } from 'react'; 6 | 7 | import { toast } from 'react-hot-toast'; 8 | 9 | export const useGetSongById = (id?: string) => { 10 | const [isLoading, setIsLoading] = useState(false); 11 | const [song, setSong] = useState(undefined); 12 | const { supabaseClient } = useSessionContext(); 13 | 14 | useEffect(() => { 15 | if (!id) { 16 | return; 17 | } 18 | 19 | setIsLoading(true); 20 | 21 | const fetchSong = async () => { 22 | const { data, error } = await supabaseClient.from('songs').select('*').eq('id', id).single(); 23 | 24 | if (error) { 25 | setIsLoading(false); 26 | toast.error(error.message); 27 | } 28 | setSong(data as Song); 29 | setIsLoading(false); 30 | }; 31 | 32 | fetchSong(); 33 | }, [id, supabaseClient]); 34 | 35 | return useMemo( 36 | () => ({ 37 | isLoading, 38 | song, 39 | }), 40 | [isLoading, song] 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /hooks/useLoadImage.ts: -------------------------------------------------------------------------------- 1 | import { Song } from '@/types'; 2 | 3 | import { useSupabaseClient } from '@supabase/auth-helpers-react'; 4 | import { supabase } from '@supabase/auth-ui-shared'; 5 | 6 | export const useLoadImage = (song: Song) => { 7 | const supabaseClient = useSupabaseClient(); 8 | 9 | if (!song) { 10 | return null; 11 | } 12 | 13 | const { data: imageData } = supabaseClient.storage.from('images').getPublicUrl(song.image_path); 14 | 15 | return imageData.publicUrl; 16 | }; 17 | -------------------------------------------------------------------------------- /hooks/useLoadSongUrl.ts: -------------------------------------------------------------------------------- 1 | import { Song } from '@/types'; 2 | import { useSupabaseClient } from '@supabase/auth-helpers-react'; 3 | 4 | export const useLoadSongUrl = (song: Song) => { 5 | const supabaseClient = useSupabaseClient(); 6 | 7 | if (!song) { 8 | return ''; 9 | } 10 | 11 | const { data: songData } = supabaseClient.storage.from('songs').getPublicUrl(song.song_path); 12 | 13 | return songData.publicUrl; 14 | }; 15 | -------------------------------------------------------------------------------- /hooks/useOnPlay.ts: -------------------------------------------------------------------------------- 1 | import { Song } from '@/types'; 2 | import { usePlayer } from './usePlayer'; 3 | import { useAuthModal } from './useAuthModal'; 4 | import { useUser } from './useUser'; 5 | import { useSubscribeModal } from './useSubscribeModal'; 6 | 7 | export const useOnPlay = (songs: Song[]) => { 8 | const subscribeModal = useSubscribeModal(); 9 | const player = usePlayer(); 10 | const authModal = useAuthModal(); 11 | const { user, subscription } = useUser(); 12 | 13 | const usePlay = (id: string) => { 14 | if (!user) { 15 | return authModal.onOpen(); 16 | } 17 | 18 | if (!subscription) { 19 | return subscribeModal.onOpen(); 20 | } 21 | 22 | player.setId(id); 23 | player.setIds(songs.map((song) => song.id)); 24 | }; 25 | return usePlay; 26 | }; 27 | -------------------------------------------------------------------------------- /hooks/usePlayer.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface PlayerStore { 4 | ids: string[]; 5 | activeId?: string; 6 | setId: (id: string) => void; 7 | setIds: (ids: string[]) => void; 8 | reset: () => void; 9 | } 10 | 11 | export const usePlayer = create((set) => ({ 12 | ids: [], 13 | activeId: undefined, 14 | setId: (id: string) => set({ activeId: id }), 15 | setIds: (ids: string[]) => set({ ids: ids }), 16 | reset: () => set({ ids: [], activeId: undefined }), 17 | })); 18 | -------------------------------------------------------------------------------- /hooks/useSubscribeModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface SubscribeModal { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | export const useSubscribeModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /hooks/useUploadModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface UploadModalStore { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | export const useUploadModal = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /hooks/useUser.tsx: -------------------------------------------------------------------------------- 1 | import { User } from '@supabase/auth-helpers-nextjs'; 2 | import { useSessionContext, useUser as useSupaUser } from '@supabase/auth-helpers-react'; 3 | import { UserDetails, Subscription } from '@/types'; 4 | import { useState, createContext, useEffect, useContext } from 'react'; 5 | 6 | //* Define a type for the user context 7 | type UserContextType = { 8 | accessToken: string | null; 9 | user: User | null; 10 | userDetails: UserDetails | null; 11 | isLoading: boolean; 12 | subscription: Subscription | null; 13 | }; 14 | 15 | //* Create a user context with the above type 16 | export const UserContext = createContext(undefined); 17 | 18 | //* Define a interface for component props 19 | export interface Props { 20 | [propName: string]: any; 21 | } 22 | 23 | //* Define a user context provider component 24 | export const MyUserContextProvider = (props: Props) => { 25 | //* Use the session context hook to get the session and loading status 26 | const { session, isLoading: isLoadingUser, supabaseClient: supabase } = useSessionContext(); 27 | 28 | //* Use the Supabase user hook to get the user 29 | const user = useSupaUser(); 30 | 31 | //* Get the access token from the session, or null if it doesn't exist 32 | const accessToken = session?.access_token ?? null; 33 | 34 | //* Create a state for loading data, user details, and subscription 35 | const [isLoadingData, setIsLoadingData] = useState(false); 36 | const [userDetails, setUserDetails] = useState(null); 37 | const [subscription, setSubscription] = useState(null); 38 | 39 | //* Define functions to get user details and subscription from Supabase 40 | const getUserDetails = () => supabase.from('users').select('*').single(); 41 | const getSubscription = () => 42 | supabase 43 | .from('subscriptions') 44 | .select('*, prices(*, products(*))') 45 | .in('status', ['trialing', 'active']) 46 | .single(); 47 | 48 | //* Fetch user info 49 | useEffect(() => { 50 | //* If user exists and data is not loading, fetch data 51 | if (user && !isLoadingData && !userDetails && !subscription) { 52 | setIsLoadingData(true); 53 | 54 | //* Use Promise.allSettled to fetch user details and subscription 55 | Promise.allSettled([getUserDetails(), getSubscription()]).then( 56 | ([userDetailsPromise, subscriptionPromise]) => { 57 | //* If the user details promise is fulfilled, set the user details state 58 | if (userDetailsPromise.status === 'fulfilled') { 59 | setUserDetails(userDetailsPromise.value?.data as UserDetails); 60 | } else { 61 | //! Log an error if the promise for details is rejected 62 | console.error(userDetailsPromise.reason); 63 | } 64 | 65 | //* If the subscription promise is fulfilled, set the subscription state 66 | if (subscriptionPromise.status === 'fulfilled') { 67 | setSubscription(subscriptionPromise.value?.data as Subscription); 68 | } else { 69 | //! Log an error if the promise for subscriptions is rejected 70 | console.error(subscriptionPromise.reason); 71 | } 72 | 73 | setIsLoadingData(false); 74 | } 75 | ); 76 | } else if (!user && !isLoadingUser && !isLoadingData) { 77 | //* If user does not exist and data is not loading, reset user details and subscription 78 | setUserDetails(null); 79 | setSubscription(null); 80 | } 81 | }, [user, isLoadingUser]); //* Run effect when user or loading user state changes 82 | 83 | //* Define the value to pass to the user context 84 | const value = { 85 | accessToken, 86 | user, 87 | userDetails, 88 | isLoading: isLoadingUser || isLoadingData, 89 | subscription, 90 | }; 91 | 92 | return ; 93 | }; 94 | 95 | export const useUser = () => { 96 | const context = useContext(UserContext); 97 | if (context === undefined) { 98 | throw new Error('useUser must be used within a MyUserContextProvider'); 99 | } 100 | return context; 101 | }; 102 | -------------------------------------------------------------------------------- /libs/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Price } from '@/types'; 2 | 3 | export const validateUuid = ( 4 | id: string | undefined | null, 5 | errorMessage = 'Invalid UUID' 6 | ): string => { 7 | if (!id || id === 'undefined') { 8 | throw new Error(errorMessage); 9 | } 10 | return id; 11 | }; 12 | 13 | export const getURL = () => { 14 | let url = 15 | process?.env?.NEXT_PUBLIC_SITE_URL ?? //* Set this to your site URL in production env. 16 | process?.env?.NEXT_PUBLIC_VERCEL_URL ?? //* Automatically set by Vercel. 17 | 'http://localhost:3000/'; 18 | //* Make sure to include `https://` when not localhost. 19 | url = url.includes('http') ? url : `https://${url}`; 20 | //* Make sure to including trailing `/`. 21 | url = url.charAt(url.length - 1) === '/' ? url : `${url}/`; 22 | return url; 23 | }; 24 | 25 | export const postData = async ({ url, data }: { url: string; data?: { price: Price } }) => { 26 | // console.log('posting,', url, data); 27 | 28 | const res: Response = await fetch(url, { 29 | method: 'POST', 30 | headers: new Headers({ 'Content-Type': 'application/json' }), 31 | credentials: 'same-origin', 32 | body: JSON.stringify(data), 33 | }); 34 | 35 | if (!res.ok) { 36 | // console.log('Error in postData', { url, data, res }); 37 | 38 | throw Error(res.statusText); 39 | } 40 | 41 | return res.json(); 42 | }; 43 | 44 | export const toDateTime = (secs: number) => { 45 | var t = new Date('1970-01-01T00:30:00Z'); 46 | t.setSeconds(secs); 47 | return t; 48 | }; 49 | -------------------------------------------------------------------------------- /libs/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | 3 | //* Initialize Stripe instance with secret key from environment variables 4 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', { 5 | apiVersion: '2022-11-15', 6 | appInfo: { 7 | name: 'Spotify Clone Application', 8 | version: '0.1.0', 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /libs/stripeClient.ts: -------------------------------------------------------------------------------- 1 | import { loadStripe, Stripe } from '@stripe/stripe-js'; 2 | 3 | //* Declare variable to store Stripe instance promise. 4 | let stripePromise: Promise; 5 | 6 | //* Function to retrieve or initialize the Stripe instance 7 | export const getStripe = () => { 8 | if (!stripePromise) { 9 | stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''); 10 | } 11 | 12 | return stripePromise; 13 | }; 14 | -------------------------------------------------------------------------------- /libs/supabaseAdmin.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { createClient } from '@supabase/supabase-js'; 3 | 4 | import { Database } from '@/types_db'; 5 | import { Price, Product } from '@/types'; 6 | 7 | import { stripe } from './stripe'; 8 | import { toDateTime, validateUuid } from './helpers'; 9 | 10 | export const supabaseAdmin = createClient( 11 | process.env.NEXT_PUBLIC_SUPABASE_URL || '', 12 | process.env.SUPABASE_SERVICE_ROLE_KEY || '' 13 | ); 14 | 15 | const upsertProductRecord = async (product: Stripe.Product) => { 16 | const productData: Product = { 17 | id: product.id, 18 | active: product.active, 19 | name: product.name, 20 | description: product.description ?? undefined, 21 | image: product.images?.[0] ?? null, 22 | metadata: product.metadata, 23 | }; 24 | 25 | const { error } = await supabaseAdmin.from('products').upsert([productData]); 26 | if (error) throw error; 27 | console.log(`Product inserted/updated: ${product.id}`); 28 | }; 29 | 30 | const upsertPriceRecord = async (price: Stripe.Price) => { 31 | const priceData: Price = { 32 | id: price.id, 33 | product_id: typeof price.product === 'string' ? price.product : '', 34 | active: price.active, 35 | currency: price.currency, 36 | description: price.nickname ?? undefined, 37 | type: price.type, 38 | unit_amount: price.unit_amount ?? undefined, 39 | interval: price.recurring?.interval, 40 | interval_count: price.recurring?.interval_count, 41 | trial_period_days: price.recurring?.trial_period_days, 42 | metadata: price.metadata, 43 | }; 44 | 45 | const { error } = await supabaseAdmin.from('prices').upsert([priceData]); 46 | if (error) throw error; 47 | // console.log(`Price inserted/updated: ${price.id}`); 48 | }; 49 | 50 | const createOrRetrieveCustomer = async ({ email, uuid }: { email: string; uuid: string }) => { 51 | if (!uuid || uuid === 'undefined') { 52 | throw new Error('Invalid UUID provided'); 53 | } 54 | 55 | const { data, error } = await supabaseAdmin 56 | .from('customers') 57 | .select('stripe_customer_id') 58 | .eq('id', uuid) 59 | .single(); 60 | if (error || !data?.stripe_customer_id) { 61 | const customerData: { metadata: { supabaseUUID: string }; email?: string } = { 62 | metadata: { 63 | supabaseUUID: uuid, 64 | }, 65 | }; 66 | if (email) customerData.email = email; 67 | const customer = await stripe.customers.create(customerData); 68 | const { error: supabaseError } = await supabaseAdmin 69 | .from('customers') 70 | .insert([{ id: uuid, stripe_customer_id: customer.id }]); 71 | if (supabaseError) throw supabaseError; 72 | // console.log(`New customer created and inserted for ${uuid}.`); 73 | return customer.id; 74 | } 75 | return data.stripe_customer_id; 76 | }; 77 | 78 | const copyBillingDetailsToCustomer = async (uuid: string, payment_method: Stripe.PaymentMethod) => { 79 | // Add validation for uuid 80 | if (!uuid || uuid === 'undefined') { 81 | throw new Error('Invalid UUID provided to copyBillingDetailsToCustomer'); 82 | } 83 | 84 | const customer = payment_method.customer as string; 85 | const { name, phone, address } = payment_method.billing_details; 86 | if (!name || !phone || !address) return; 87 | //@ts-ignore 88 | await stripe.customers.update(customer, { name, phone, address }); 89 | const { error } = await supabaseAdmin 90 | .from('users') 91 | .update({ 92 | billing_address: { ...address }, 93 | payment_method: { ...payment_method[payment_method.type] }, 94 | }) 95 | .eq('id', uuid); 96 | if (error) throw error; 97 | }; 98 | 99 | const manageSubscriptionStatusChange = async ( 100 | subscriptionId: string, 101 | customerId: string, 102 | createAction = false 103 | ) => { 104 | // Add validation for subscription and customer IDs 105 | if (!subscriptionId || subscriptionId === 'undefined') { 106 | throw new Error('Invalid subscription ID provided'); 107 | } 108 | 109 | if (!customerId || customerId === 'undefined') { 110 | throw new Error('Invalid customer ID provided'); 111 | } 112 | 113 | // Get customer's UUID from mapping table. 114 | const { data: customerData, error: noCustomerError } = await supabaseAdmin 115 | .from('customers') 116 | .select('id') 117 | .eq('stripe_customer_id', customerId) 118 | .single(); 119 | if (noCustomerError) throw noCustomerError; 120 | 121 | // Validate that customerData exists and has an id 122 | if (!customerData || !customerData.id) { 123 | throw new Error(`No customer found with Stripe ID: ${customerId}`); 124 | } 125 | 126 | const { id: uuid } = customerData!; 127 | 128 | // Validate uuid after extraction 129 | if (!uuid || uuid === 'undefined') { 130 | throw new Error(`Invalid UUID extracted from customer data for Stripe ID: ${customerId}`); 131 | } 132 | 133 | const subscription = await stripe.subscriptions.retrieve(subscriptionId, { 134 | expand: ['default_payment_method'], 135 | }); 136 | // Upsert the latest status of the subscription object. 137 | const subscriptionData: Database['public']['Tables']['subscriptions']['Insert'] = { 138 | id: subscription.id, 139 | user_id: uuid, 140 | metadata: subscription.metadata, 141 | // @ts-ignore 142 | status: subscription.status, 143 | price_id: subscription.items.data[0].price.id, 144 | // @ts-ignore 145 | quantity: subscription.quantity, 146 | cancel_at_period_end: subscription.cancel_at_period_end, 147 | cancel_at: subscription.cancel_at ? toDateTime(subscription.cancel_at).toISOString() : null, 148 | canceled_at: subscription.canceled_at 149 | ? toDateTime(subscription.canceled_at).toISOString() 150 | : null, 151 | current_period_start: toDateTime(subscription.current_period_start).toISOString(), 152 | current_period_end: toDateTime(subscription.current_period_end).toISOString(), 153 | created: toDateTime(subscription.created).toISOString(), 154 | ended_at: subscription.ended_at ? toDateTime(subscription.ended_at).toISOString() : null, 155 | trial_start: subscription.trial_start 156 | ? toDateTime(subscription.trial_start).toISOString() 157 | : null, 158 | trial_end: subscription.trial_end ? toDateTime(subscription.trial_end).toISOString() : null, 159 | }; 160 | 161 | const { error } = await supabaseAdmin.from('subscriptions').upsert([subscriptionData]); 162 | if (error) throw error; 163 | // console.log(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`); 164 | 165 | // For a new subscription copy the billing details to the customer object. 166 | // NOTE: This is a costly operation and should happen at the very end. 167 | if (createAction && subscription.default_payment_method && uuid) 168 | //@ts-ignore 169 | await copyBillingDetailsToCustomer( 170 | uuid, 171 | subscription.default_payment_method as Stripe.PaymentMethod 172 | ); 173 | }; 174 | 175 | export { 176 | upsertProductRecord, 177 | upsertPriceRecord, 178 | createOrRetrieveCustomer, 179 | manageSubscriptionStatusChange, 180 | }; 181 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { createMiddlewareClient } from '@supabase/auth-helpers-nextjs'; 2 | 3 | import { NextRequest, NextResponse } from 'next/server'; 4 | 5 | export async function middleware(req: NextRequest) { 6 | const res = NextResponse.next(); 7 | const supabase = createMiddlewareClient({ 8 | req, 9 | res, 10 | }); 11 | 12 | await supabase.auth.getSession(); 13 | return res; 14 | } 15 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('next').NextConfig} 3 | */ 4 | const nextConfig = { 5 | images: { 6 | remotePatterns: [ 7 | { 8 | protocol: 'https', 9 | hostname: 'meggkopiinwpefsqnqac.supabase.co', 10 | port: '', 11 | pathname: '/storage/v1/object/public/images/**', 12 | }, 13 | ], 14 | }, 15 | }; 16 | 17 | module.exports = nextConfig; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spotify", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-dialog": "^1.0.4", 13 | "@radix-ui/react-slider": "^1.1.2", 14 | "@stripe/stripe-js": "^1.54.1", 15 | "@supabase/auth-helpers-nextjs": "^0.7.2", 16 | "@supabase/auth-helpers-react": "^0.4.0", 17 | "@supabase/auth-ui-react": "^0.4.2", 18 | "@supabase/auth-ui-shared": "^0.1.6", 19 | "@supabase/supabase-js": "^2.26.0", 20 | "@types/node": "20.3.1", 21 | "@types/react": "18.2.12", 22 | "@types/react-dom": "18.2.5", 23 | "@vercel/analytics": "^1.0.1", 24 | "autoprefixer": "10.4.14", 25 | "eslint": "8.43.0", 26 | "eslint-config-next": "13.4.6", 27 | "he": "^1.2.0", 28 | "next": "^14.1.4", 29 | "postcss": "^8.5.3", 30 | "prettier": "^2.8.8", 31 | "query-string": "^8.1.0", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-hook-form": "^7.45.0", 35 | "react-hot-toast": "^2.4.1", 36 | "react-icons": "^4.9.0", 37 | "react-loader-spinner": "^5.3.4", 38 | "react-spinners": "^0.13.8", 39 | "stripe": "^12.9.0", 40 | "tailwind-merge": "^1.13.2", 41 | "tailwindcss": "3.3.2", 42 | "uniqid": "^5.4.0", 43 | "use-sound": "^4.0.1", 44 | "zod": "^3.24.4", 45 | "zustand": "^4.3.8" 46 | }, 47 | "devDependencies": { 48 | "@types/he": "^1.2.3", 49 | "@types/uniqid": "^5.3.2", 50 | "supabase": "^1.68.6", 51 | "typescript": "^5.1.6" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /providers/ModalProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | 5 | import { AuthModal } from '@/components/AuthModal'; 6 | import { UploadModal } from '@/components/UploadModal'; 7 | import SubscribeModal from '@/components/SubscribeModal'; 8 | import { ProductWithPrice } from '@/types'; 9 | 10 | interface ModalProviderProps { 11 | products: ProductWithPrice[]; 12 | } 13 | 14 | export const ModalProvider: React.FC = ({ products }) => { 15 | const [isMounted, setIsMounted] = useState(false); 16 | 17 | //* useEffect hook to set isMounted to true after the initial render 18 | //* This prevents the modals from rendering on the server-side (SSR) 19 | useEffect(() => { 20 | setIsMounted(true); 21 | }, []); 22 | 23 | //* If the component is not mounted, don't render anything 24 | if (!isMounted) { 25 | return null; 26 | } 27 | 28 | //* Once the component is mounted, render the AuthModal and UploadModal components 29 | return ( 30 | <> 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /providers/SupabaseProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | 5 | import { Database } from '@/types_db'; 6 | import { createClientComponentClient, SupabaseClient } from '@supabase/auth-helpers-nextjs'; 7 | import { SessionContextProvider } from '@supabase/auth-helpers-react'; 8 | 9 | //* Props type for the SupabaseProvider component 10 | interface SupabaseProviderProps { 11 | children: React.ReactNode; 12 | } 13 | 14 | //* SupabaseProvider component definition 15 | export const SupabaseProvider: React.FC = ({ children }) => { 16 | //* Using React's useState to hold the Supabase client object 17 | const [supabaseClient, setSupabaseClient] = useState(null); 18 | 19 | //* useEffect hook to initialize the Supabase client when this component mounts 20 | useEffect(() => { 21 | const client = createClientComponentClient(); 22 | setSupabaseClient(client); 23 | }, []); 24 | 25 | //* If the Supabase client is not initialized, render nothing (or a loading spinner) 26 | if (!supabaseClient) { 27 | return null; //* or a loading spinner 28 | } 29 | 30 | //* If the Supabase client is available, render the children within the SessionContextProvider 31 | return ( 32 | {children} 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /providers/ToasterProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Toaster } from 'react-hot-toast'; 4 | 5 | export const ToasterProvider = () => { 6 | return ( 7 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /providers/UserProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MyUserContextProvider } from '@/hooks/useUser'; 4 | 5 | interface UserProviderProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export const UserProvider: React.FC = ({ children }) => { 10 | return {children}; 11 | }; 12 | -------------------------------------------------------------------------------- /public/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrwebwork/spotify/d8a03103c32d48c157374afa0e8be8f23f9e2ac1/public/images/apple-touch-icon.png -------------------------------------------------------------------------------- /public/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrwebwork/spotify/d8a03103c32d48c157374afa0e8be8f23f9e2ac1/public/images/favicon-16x16.png -------------------------------------------------------------------------------- /public/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrwebwork/spotify/d8a03103c32d48c157374afa0e8be8f23f9e2ac1/public/images/favicon-32x32.png -------------------------------------------------------------------------------- /public/images/liked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrwebwork/spotify/d8a03103c32d48c157374afa0e8be8f23f9e2ac1/public/images/liked.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | 3 | export interface Song { 4 | id: string; 5 | user_id: string; 6 | author: string; 7 | title: string; 8 | song_path: string; 9 | image_path: string; 10 | } 11 | 12 | export interface UserDetails { 13 | id: string; 14 | first_name: string; 15 | last_name: string; 16 | full_name?: string; 17 | avatar_url?: string; 18 | billing_address?: Stripe.Address; 19 | payment_method?: Stripe.PaymentMethod[Stripe.PaymentMethod.Type]; 20 | } 21 | 22 | export interface Product { 23 | id: string; 24 | active?: boolean; 25 | name?: string; 26 | description?: string; 27 | image?: string; 28 | metadata?: Stripe.Metadata; 29 | } 30 | 31 | export interface Price { 32 | id: string; 33 | product_id?: string; 34 | active?: boolean; 35 | description?: string; 36 | unit_amount?: number; 37 | currency?: string; 38 | type?: Stripe.Price.Type; 39 | interval?: Stripe.Price.Recurring.Interval; 40 | interval_count?: number; 41 | trial_period_days?: number | null; 42 | metadata?: Stripe.Metadata; 43 | products?: Product; 44 | } 45 | 46 | export interface ProductWithPrice extends Product { 47 | prices?: Price[]; 48 | } 49 | 50 | export interface Subscription { 51 | id: string; 52 | user_id: string; 53 | status?: Stripe.Subscription.Status; 54 | metadata?: Stripe.Metadata; 55 | price_id?: string; 56 | quantity?: number; 57 | cancel_at_period_end?: boolean; 58 | created: string; 59 | current_period_start: string; 60 | current_period_end: string; 61 | ended_at?: string; 62 | cancel_at?: string; 63 | canceled_at?: string; 64 | trial_start?: string; 65 | trial_end?: string; 66 | prices?: Price; 67 | } 68 | -------------------------------------------------------------------------------- /types_db.ts: -------------------------------------------------------------------------------- 1 | export type Json = string | number | boolean | null | { [key: string]: Json } | Json[]; 2 | 3 | export interface Database { 4 | public: { 5 | Tables: { 6 | customers: { 7 | Row: { 8 | id: string; 9 | stripe_customer_id: string | null; 10 | }; 11 | Insert: { 12 | id: string; 13 | stripe_customer_id?: string | null; 14 | }; 15 | Update: { 16 | id?: string; 17 | stripe_customer_id?: string | null; 18 | }; 19 | Relationships: [ 20 | { 21 | foreignKeyName: 'customers_id_fkey'; 22 | columns: ['id']; 23 | referencedRelation: 'users'; 24 | referencedColumns: ['id']; 25 | } 26 | ]; 27 | }; 28 | liked_songs: { 29 | Row: { 30 | created_at: string | null; 31 | song_id: number; 32 | user_id: string; 33 | }; 34 | Insert: { 35 | created_at?: string | null; 36 | song_id: number; 37 | user_id: string; 38 | }; 39 | Update: { 40 | created_at?: string | null; 41 | song_id?: number; 42 | user_id?: string; 43 | }; 44 | Relationships: [ 45 | { 46 | foreignKeyName: 'liked_songs_song_id_fkey'; 47 | columns: ['song_id']; 48 | referencedRelation: 'songs'; 49 | referencedColumns: ['id']; 50 | }, 51 | { 52 | foreignKeyName: 'liked_songs_user_id_fkey'; 53 | columns: ['user_id']; 54 | referencedRelation: 'users'; 55 | referencedColumns: ['id']; 56 | } 57 | ]; 58 | }; 59 | prices: { 60 | Row: { 61 | active: boolean | null; 62 | currency: string | null; 63 | description: string | null; 64 | id: string; 65 | interval: Database['public']['Enums']['pricing_plan_interval'] | null; 66 | interval_count: number | null; 67 | metadata: Json | null; 68 | product_id: string | null; 69 | trial_period_days: number | null; 70 | type: Database['public']['Enums']['pricing_type'] | null; 71 | unit_amount: number | null; 72 | }; 73 | Insert: { 74 | active?: boolean | null; 75 | currency?: string | null; 76 | description?: string | null; 77 | id: string; 78 | interval?: Database['public']['Enums']['pricing_plan_interval'] | null; 79 | interval_count?: number | null; 80 | metadata?: Json | null; 81 | product_id?: string | null; 82 | trial_period_days?: number | null; 83 | type?: Database['public']['Enums']['pricing_type'] | null; 84 | unit_amount?: number | null; 85 | }; 86 | Update: { 87 | active?: boolean | null; 88 | currency?: string | null; 89 | description?: string | null; 90 | id?: string; 91 | interval?: Database['public']['Enums']['pricing_plan_interval'] | null; 92 | interval_count?: number | null; 93 | metadata?: Json | null; 94 | product_id?: string | null; 95 | trial_period_days?: number | null; 96 | type?: Database['public']['Enums']['pricing_type'] | null; 97 | unit_amount?: number | null; 98 | }; 99 | Relationships: [ 100 | { 101 | foreignKeyName: 'prices_product_id_fkey'; 102 | columns: ['product_id']; 103 | referencedRelation: 'products'; 104 | referencedColumns: ['id']; 105 | } 106 | ]; 107 | }; 108 | products: { 109 | Row: { 110 | active: boolean | null; 111 | description: string | null; 112 | id: string; 113 | image: string | null; 114 | metadata: Json | null; 115 | name: string | null; 116 | }; 117 | Insert: { 118 | active?: boolean | null; 119 | description?: string | null; 120 | id: string; 121 | image?: string | null; 122 | metadata?: Json | null; 123 | name?: string | null; 124 | }; 125 | Update: { 126 | active?: boolean | null; 127 | description?: string | null; 128 | id?: string; 129 | image?: string | null; 130 | metadata?: Json | null; 131 | name?: string | null; 132 | }; 133 | Relationships: []; 134 | }; 135 | songs: { 136 | Row: { 137 | author: string | null; 138 | created_at: string | null; 139 | id: number; 140 | image_path: string | null; 141 | song_path: string | null; 142 | title: string | null; 143 | user_id: string | null; 144 | }; 145 | Insert: { 146 | author?: string | null; 147 | created_at?: string | null; 148 | id?: number; 149 | image_path?: string | null; 150 | song_path?: string | null; 151 | title?: string | null; 152 | user_id?: string | null; 153 | }; 154 | Update: { 155 | author?: string | null; 156 | created_at?: string | null; 157 | id?: number; 158 | image_path?: string | null; 159 | song_path?: string | null; 160 | title?: string | null; 161 | user_id?: string | null; 162 | }; 163 | Relationships: [ 164 | { 165 | foreignKeyName: 'songs_user_id_fkey'; 166 | columns: ['user_id']; 167 | referencedRelation: 'users'; 168 | referencedColumns: ['id']; 169 | } 170 | ]; 171 | }; 172 | subscriptions: { 173 | Row: { 174 | cancel_at: string | null; 175 | cancel_at_period_end: boolean | null; 176 | canceled_at: string | null; 177 | created: string; 178 | current_period_end: string; 179 | current_period_start: string; 180 | ended_at: string | null; 181 | id: string; 182 | metadata: Json | null; 183 | price_id: string | null; 184 | quantity: number | null; 185 | status: Database['public']['Enums']['subscription_status'] | null; 186 | trial_end: string | null; 187 | trial_start: string | null; 188 | user_id: string; 189 | }; 190 | Insert: { 191 | cancel_at?: string | null; 192 | cancel_at_period_end?: boolean | null; 193 | canceled_at?: string | null; 194 | created?: string; 195 | current_period_end?: string; 196 | current_period_start?: string; 197 | ended_at?: string | null; 198 | id: string; 199 | metadata?: Json | null; 200 | price_id?: string | null; 201 | quantity?: number | null; 202 | status?: Database['public']['Enums']['subscription_status'] | null; 203 | trial_end?: string | null; 204 | trial_start?: string | null; 205 | user_id: string; 206 | }; 207 | Update: { 208 | cancel_at?: string | null; 209 | cancel_at_period_end?: boolean | null; 210 | canceled_at?: string | null; 211 | created?: string; 212 | current_period_end?: string; 213 | current_period_start?: string; 214 | ended_at?: string | null; 215 | id?: string; 216 | metadata?: Json | null; 217 | price_id?: string | null; 218 | quantity?: number | null; 219 | status?: Database['public']['Enums']['subscription_status'] | null; 220 | trial_end?: string | null; 221 | trial_start?: string | null; 222 | user_id?: string; 223 | }; 224 | Relationships: [ 225 | { 226 | foreignKeyName: 'subscriptions_price_id_fkey'; 227 | columns: ['price_id']; 228 | referencedRelation: 'prices'; 229 | referencedColumns: ['id']; 230 | }, 231 | { 232 | foreignKeyName: 'subscriptions_user_id_fkey'; 233 | columns: ['user_id']; 234 | referencedRelation: 'users'; 235 | referencedColumns: ['id']; 236 | } 237 | ]; 238 | }; 239 | users: { 240 | Row: { 241 | avatar_url: string | null; 242 | billing_address: Json | null; 243 | full_name: string | null; 244 | id: string; 245 | payment_method: Json | null; 246 | }; 247 | Insert: { 248 | avatar_url?: string | null; 249 | billing_address?: Json | null; 250 | full_name?: string | null; 251 | id: string; 252 | payment_method?: Json | null; 253 | }; 254 | Update: { 255 | avatar_url?: string | null; 256 | billing_address?: Json | null; 257 | full_name?: string | null; 258 | id?: string; 259 | payment_method?: Json | null; 260 | }; 261 | Relationships: [ 262 | { 263 | foreignKeyName: 'users_id_fkey'; 264 | columns: ['id']; 265 | referencedRelation: 'users'; 266 | referencedColumns: ['id']; 267 | } 268 | ]; 269 | }; 270 | }; 271 | Views: { 272 | [_ in never]: never; 273 | }; 274 | Functions: { 275 | [_ in never]: never; 276 | }; 277 | Enums: { 278 | pricing_plan_interval: 'day' | 'week' | 'month' | 'year'; 279 | pricing_type: 'one_time' | 'recurring'; 280 | subscription_status: 281 | | 'trialing' 282 | | 'active' 283 | | 'canceled' 284 | | 'incomplete' 285 | | 'incomplete_expired' 286 | | 'past_due' 287 | | 'unpaid'; 288 | }; 289 | CompositeTypes: { 290 | [_ in never]: never; 291 | }; 292 | }; 293 | } 294 | --------------------------------------------------------------------------------