├── constants └── movie.ts ├── prettier.config.js ├── public ├── favicon.ico └── vercel.svg ├── postcss.config.js ├── next-env.d.ts ├── next.config.js ├── atoms └── modalAtom.ts ├── pages ├── api │ └── hello.ts ├── _app.tsx ├── login.tsx ├── account.tsx └── index.tsx ├── .gitignore ├── tailwind.config.js ├── tsconfig.json ├── typings.d.ts ├── hooks ├── useList.tsx ├── useSubscription.tsx └── useAuth.tsx ├── utils └── requests.ts ├── components ├── Thumbnail.tsx ├── Loader.tsx ├── BasicMenu.tsx ├── Header.tsx ├── Row.tsx ├── Banner.tsx ├── Membership.tsx ├── Table.tsx ├── Plans.tsx └── Modal.tsx ├── firebase.ts ├── package.json ├── lib └── stripe.ts ├── README.md └── styles └── globals.css /constants/movie.ts: -------------------------------------------------------------------------------- 1 | export const baseUrl = 'https://image.tmdb.org/t/p/original/' 2 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | semi: false, 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukef7fywmrp/netflix-clone-yt/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withTM = require('next-transpile-modules')([ 2 | '@stripe/firestore-stripe-payments', 3 | ]) // pass the modules you would like to see transpiled 4 | 5 | module.exports = withTM({ 6 | reactStrictMode: true, 7 | images: { 8 | domains: ['rb.gy', 'image.tmdb.org'], 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /atoms/modalAtom.ts: -------------------------------------------------------------------------------- 1 | import { DocumentData } from 'firebase/firestore' 2 | import { atom } from 'recoil' 3 | import { Movie } from '../typings' 4 | 5 | export const modalState = atom({ 6 | key: 'modalState', 7 | default: false, 8 | }) 9 | 10 | export const movieState = atom({ 11 | key: 'movieState', 12 | default: null, 13 | }) 14 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | import { AuthProvider } from '../hooks/useAuth' 4 | import { RecoilRoot } from 'recoil' 5 | 6 | function MyApp({ Component, pageProps }: AppProps) { 7 | return ( 8 | 9 | {/* Higher Order Component */} 10 | 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default MyApp 18 | -------------------------------------------------------------------------------- /.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 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './pages/**/*.{js,ts,jsx,tsx}', 4 | './components/**/*.{js,ts,jsx,tsx}', 5 | ], 6 | theme: { 7 | extend: { 8 | backgroundImage: { 9 | 'gradient-to-b': 10 | 'linear-gradient(to bottom,rgba(20,20,20,0) 0,rgba(20,20,20,.15) 15%,rgba(20,20,20,.35) 29%,rgba(20,20,20,.58) 44%,#141414 68%,#141414 100%);', 11 | }, 12 | }, 13 | }, 14 | plugins: [ 15 | require('tailwindcss-textshadow'), 16 | require('tailwind-scrollbar-hide'), 17 | require('tailwind-scrollbar'), 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /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 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | export interface Genre { 2 | id: number 3 | name: string 4 | } 5 | 6 | export interface Movie { 7 | title: string 8 | backdrop_path: string 9 | media_type?: string 10 | release_date?: string 11 | first_air_date: string 12 | genre_ids: number[] 13 | id: number 14 | name: string 15 | origin_country: string[] 16 | original_language: string 17 | original_name: string 18 | overview: string 19 | popularity: number 20 | poster_path: string 21 | vote_average: number 22 | vote_count: number 23 | } 24 | 25 | export interface Element { 26 | type: 27 | | 'Bloopers' 28 | | 'Featurette' 29 | | 'Behind the Scenes' 30 | | 'Clip' 31 | | 'Trailer' 32 | | 'Teaser' 33 | } 34 | -------------------------------------------------------------------------------- /hooks/useList.tsx: -------------------------------------------------------------------------------- 1 | import { collection, DocumentData, onSnapshot } from 'firebase/firestore' 2 | import { useEffect, useState } from 'react' 3 | import { db } from '../firebase' 4 | import { Movie } from '../typings' 5 | 6 | function useList(uid: string | undefined) { 7 | const [list, setList] = useState([]) 8 | 9 | useEffect(() => { 10 | if (!uid) return 11 | 12 | return onSnapshot( 13 | collection(db, 'customers', uid, 'myList'), 14 | (snapshot) => { 15 | setList( 16 | snapshot.docs.map((doc) => ({ 17 | id: doc.id, 18 | ...doc.data(), 19 | })) 20 | ) 21 | } 22 | ) 23 | }, [db, uid]) 24 | 25 | return list 26 | } 27 | 28 | export default useList 29 | -------------------------------------------------------------------------------- /hooks/useSubscription.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | onCurrentUserSubscriptionUpdate, 3 | Subscription, 4 | } from '@stripe/firestore-stripe-payments' 5 | import { User } from 'firebase/auth' 6 | import { useEffect, useState } from 'react' 7 | import payments from '../lib/stripe' 8 | 9 | function useSubscription(user: User | null) { 10 | const [subscription, setSubscription] = useState(null) 11 | 12 | useEffect(() => { 13 | if (!user) return 14 | 15 | onCurrentUserSubscriptionUpdate(payments, (snapshot) => { 16 | setSubscription( 17 | snapshot.subscriptions.filter( 18 | (subscription) => 19 | subscription.status === 'active' || 20 | subscription.status === 'trialing' 21 | )[0] 22 | ) 23 | }) 24 | }, [user]) 25 | 26 | return subscription 27 | } 28 | 29 | export default useSubscription 30 | -------------------------------------------------------------------------------- /utils/requests.ts: -------------------------------------------------------------------------------- 1 | const API_KEY = process.env.NEXT_PUBLIC_API_KEY 2 | const BASE_URL = 'https://api.themoviedb.org/3' 3 | 4 | const requests = { 5 | fetchTrending: `${BASE_URL}/trending/all/week?api_key=${API_KEY}&language=en-US`, 6 | fetchNetflixOriginals: `${BASE_URL}/discover/movie?api_key=${API_KEY}&with_networks=213`, 7 | fetchTopRated: `${BASE_URL}/movie/top_rated?api_key=${API_KEY}&language=en-US`, 8 | fetchActionMovies: `${BASE_URL}/discover/movie?api_key=${API_KEY}&language=en-US&with_genres=28`, 9 | fetchComedyMovies: `${BASE_URL}/discover/movie?api_key=${API_KEY}&language=en-US&with_genres=35`, 10 | fetchHorrorMovies: `${BASE_URL}/discover/movie?api_key=${API_KEY}&language=en-US&with_genres=27`, 11 | fetchRomanceMovies: `${BASE_URL}/discover/movie?api_key=${API_KEY}&language=en-US&with_genres=10749`, 12 | fetchDocumentaries: `${BASE_URL}/discover/movie?api_key=${API_KEY}&language=en-US&with_genres=99`, 13 | } 14 | 15 | export default requests 16 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /components/Thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import { DocumentData } from 'firebase/firestore' 2 | import Image from 'next/image' 3 | import { useRecoilState } from 'recoil' 4 | import { modalState, movieState } from '../atoms/modalAtom' 5 | import { Movie } from '../typings' 6 | 7 | interface Props { 8 | movie: Movie | DocumentData 9 | } 10 | 11 | function Thumbnail({ movie }: Props) { 12 | const [showModal, setShowModal] = useRecoilState(modalState) 13 | const [currentMovie, setCurrentMovie] = useRecoilState(movieState) 14 | 15 | return ( 16 |
{ 19 | setCurrentMovie(movie) 20 | setShowModal(true) 21 | }} 22 | > 23 | 30 |
31 | ) 32 | } 33 | 34 | export default Thumbnail 35 | -------------------------------------------------------------------------------- /firebase.ts: -------------------------------------------------------------------------------- 1 | // Import the functions you need from the SDKs you need 2 | import { initializeApp, getApp, getApps } from 'firebase/app' 3 | import { getFirestore } from 'firebase/firestore' 4 | import { getAuth } from 'firebase/auth' 5 | 6 | // TODO: Add SDKs for Firebase products that you want to use 7 | // https://firebase.google.com/docs/web/setup#available-libraries 8 | 9 | // Your web app's Firebase configuration 10 | // For Firebase JS SDK v7.20.0 and later, measurementId is optional 11 | const firebaseConfig = { 12 | apiKey: 'AIzaSyBuu0YHXOcgmqWLZl-fiMn6lG2wUhdPg8k', 13 | authDomain: 'next-firebase-stripe-39bf8.firebaseapp.com', 14 | databaseURL: 'https://next-firebase-stripe-39bf8-default-rtdb.firebaseio.com', 15 | projectId: 'next-firebase-stripe-39bf8', 16 | storageBucket: 'next-firebase-stripe-39bf8.appspot.com', 17 | messagingSenderId: '777709922250', 18 | appId: '1:777709922250:web:4500ee09dca93e1406d133', 19 | } 20 | // Initialize Firebase 21 | const app = !getApps().length ? initializeApp(firebaseConfig) : getApp() 22 | const db = getFirestore() 23 | const auth = getAuth() 24 | 25 | export default app 26 | export { auth, db } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start" 7 | }, 8 | "dependencies": { 9 | "@emotion/react": "^11.9.0", 10 | "@emotion/styled": "^11.8.1", 11 | "@heroicons/react": "^1.0.6", 12 | "@mui/material": "^5.6.1", 13 | "@stripe/firestore-stripe-payments": "^0.0.6", 14 | "firebase": "^9.6.10", 15 | "next": "latest", 16 | "next-transpile-modules": "^9.0.0", 17 | "react": "^17.0.2", 18 | "react-dom": "^17.0.2", 19 | "react-hook-form": "^7.29.0", 20 | "react-hot-toast": "^2.2.0", 21 | "react-icons": "^4.3.1", 22 | "react-player": "^2.10.0", 23 | "recoil": "^0.7.1", 24 | "tailwind-scrollbar-hide": "^1.1.7" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "17.0.4", 28 | "@types/react": "17.0.38", 29 | "autoprefixer": "^10.4.0", 30 | "postcss": "^8.4.5", 31 | "prettier": "^2.5.1", 32 | "prettier-plugin-tailwindcss": "^0.1.1", 33 | "tailwind-scrollbar": "^1.3.1", 34 | "tailwindcss": "^3.0.7", 35 | "tailwindcss-textshadow": "^2.1.3", 36 | "typescript": "4.5.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createCheckoutSession, 3 | getStripePayments, 4 | } from '@stripe/firestore-stripe-payments' 5 | import { getFunctions, httpsCallable } from '@firebase/functions' 6 | import app from '../firebase' 7 | 8 | const payments = getStripePayments(app, { 9 | productsCollection: 'products', 10 | customersCollection: 'customers', 11 | }) 12 | 13 | const loadCheckout = async (priceId: string) => { 14 | await createCheckoutSession(payments, { 15 | price: priceId, 16 | success_url: window.location.origin, 17 | cancel_url: window.location.origin, 18 | }) 19 | .then((snapshot) => window.location.assign(snapshot.url)) 20 | .catch((error) => console.log(error.message)) 21 | } 22 | 23 | const goToBillingPortal = async () => { 24 | const instance = getFunctions(app, 'us-central1') 25 | const functionRef = httpsCallable( 26 | instance, 27 | 'ext-firestore-stripe-payments-createPortalLink' 28 | ) 29 | 30 | await functionRef({ 31 | returnUrl: `${window.location.origin}/account`, 32 | }) 33 | .then(({ data }: any) => window.location.assign(data.url)) 34 | .catch((error) => console.log(error.message)) 35 | } 36 | 37 | export { loadCheckout, goToBillingPortal } 38 | export default payments 39 | -------------------------------------------------------------------------------- /components/Loader.tsx: -------------------------------------------------------------------------------- 1 | function Loader({ color }: { color: string }) { 2 | return ( 3 | 10 | 14 | 18 | 19 | ) 20 | } 21 | 22 | export default Loader 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js + Tailwind CSS Example 2 | 3 | This example shows how to use [Tailwind CSS](https://tailwindcss.com/) [(v3.0)](https://tailwindcss.com/blog/tailwindcss-v3) with Next.js. It follows the steps outlined in the official [Tailwind docs](https://tailwindcss.com/docs/guides/nextjs). 4 | 5 | ## Deploy your own 6 | 7 | Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) or preview live with [StackBlitz](https://stackblitz.com/github/vercel/next.js/tree/canary/examples/with-tailwindcss) 8 | 9 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-tailwindcss&project-name=with-tailwindcss&repository-name=with-tailwindcss) 10 | 11 | ## How to use 12 | 13 | Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: 14 | 15 | ```bash 16 | npx create-next-app --example with-tailwindcss with-tailwindcss-app 17 | # or 18 | yarn create next-app --example with-tailwindcss with-tailwindcss-app 19 | # or 20 | pnpm create next-app -- --example with-tailwindcss with-tailwindcss-app 21 | ``` 22 | 23 | Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). 24 | -------------------------------------------------------------------------------- /components/BasicMenu.tsx: -------------------------------------------------------------------------------- 1 | import Button from '@mui/material/Button' 2 | import Menu from '@mui/material/Menu' 3 | import MenuItem from '@mui/material/MenuItem' 4 | import { useState } from 'react' 5 | 6 | export default function BasicMenu() { 7 | const [anchorEl, setAnchorEl] = useState(null) 8 | const open = Boolean(anchorEl) 9 | 10 | const handleClick = (event: React.MouseEvent) => { 11 | setAnchorEl(event.currentTarget) 12 | } 13 | 14 | const handleClose = () => { 15 | setAnchorEl(null) 16 | } 17 | 18 | return ( 19 |
20 | 30 | 40 | Home 41 | TV Shows 42 | Movies 43 | New & Popular 44 | My List 45 | 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { BellIcon, SearchIcon } from '@heroicons/react/solid' 2 | import Link from 'next/link' 3 | import { useEffect, useState } from 'react' 4 | import useAuth from '../hooks/useAuth' 5 | import BasicMenu from './BasicMenu' 6 | 7 | function Header() { 8 | const [isScrolled, setIsScrolled] = useState(false) 9 | const { logout } = useAuth() 10 | 11 | useEffect(() => { 12 | const handleScroll = () => { 13 | if (window.scrollY > 0) { 14 | setIsScrolled(true) 15 | } else { 16 | setIsScrolled(false) 17 | } 18 | } 19 | 20 | window.addEventListener('scroll', handleScroll) 21 | 22 | return () => { 23 | window.removeEventListener('scroll', handleScroll) 24 | } 25 | }, []) 26 | 27 | return ( 28 |
29 |
30 | 36 | 37 | 38 | 39 |
    40 |
  • Home
  • 41 |
  • TV Shows
  • 42 |
  • Movies
  • 43 |
  • New & Popular
  • 44 |
  • My List
  • 45 |
46 |
47 | 48 |
49 | 50 |

Kids

51 | 52 | 53 | 58 | 59 |
60 |
61 | ) 62 | } 63 | 64 | export default Header 65 | -------------------------------------------------------------------------------- /components/Row.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/outline' 2 | import { DocumentData } from 'firebase/firestore' 3 | import { useRef, useState } from 'react' 4 | import { Movie } from '../typings' 5 | import Thumbnail from './Thumbnail' 6 | 7 | interface Props { 8 | title: string 9 | movies: Movie[] | DocumentData[] 10 | } 11 | 12 | function Row({ title, movies }: Props) { 13 | const rowRef = useRef(null) 14 | const [isMoved, setIsMoved] = useState(false) 15 | 16 | const handleClick = (direction: string) => { 17 | setIsMoved(true) 18 | 19 | if (rowRef.current) { 20 | const { scrollLeft, clientWidth } = rowRef.current 21 | 22 | const scrollTo = 23 | direction === 'left' 24 | ? scrollLeft - clientWidth 25 | : scrollLeft + clientWidth 26 | 27 | rowRef.current.scrollTo({ left: scrollTo, behavior: 'smooth' }) 28 | } 29 | } 30 | 31 | return ( 32 |
33 |

34 | {title} 35 |

36 |
37 | handleClick('left')} 42 | /> 43 | 44 |
48 | {movies.map((movie) => ( 49 | 50 | ))} 51 |
52 | 53 | handleClick('right')} 56 | /> 57 |
58 |
59 | ) 60 | } 61 | 62 | export default Row 63 | -------------------------------------------------------------------------------- /components/Banner.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | import { useEffect, useState } from 'react' 3 | import { baseUrl } from '../constants/movie' 4 | import { Movie } from '../typings' 5 | import { FaPlay } from 'react-icons/fa' 6 | import { InformationCircleIcon } from '@heroicons/react/solid' 7 | import { useRecoilState } from 'recoil' 8 | import { modalState, movieState } from '../atoms/modalAtom' 9 | 10 | interface Props { 11 | netflixOriginals: Movie[] 12 | } 13 | 14 | function Banner({ netflixOriginals }: Props) { 15 | const [movie, setMovie] = useState(null) 16 | const [showModal, setShowModal] = useRecoilState(modalState) 17 | const [currentMovie, setCurrentMovie] = useRecoilState(movieState) 18 | 19 | useEffect(() => { 20 | setMovie( 21 | netflixOriginals[Math.floor(Math.random() * netflixOriginals.length)] 22 | ) 23 | }, [netflixOriginals]) 24 | 25 | return ( 26 |
27 |
28 | 33 |
34 | 35 |

36 | {movie?.title || movie?.name || movie?.original_name} 37 |

38 |

39 | {movie?.overview} 40 |

41 | 42 |
43 | 46 | 55 |
56 |
57 | ) 58 | } 59 | 60 | export default Banner 61 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* html, body, heading */ 6 | @layer base { 7 | body { 8 | @apply bg-[#141414] text-white !scrollbar-thin !scrollbar-track-transparent !scrollbar-thumb-red-600; 9 | } 10 | 11 | header { 12 | @apply fixed top-0 z-50 flex w-full items-center justify-between px-4 py-4 transition-all lg:px-10 lg:py-6; 13 | } 14 | } 15 | 16 | /* custom classNames */ 17 | @layer components { 18 | .headerLink { 19 | @apply cursor-pointer text-sm font-light text-[#e5e5e5] transition duration-[.4s] hover:text-[#b3b3b3]; 20 | } 21 | 22 | .bannerButton { 23 | @apply flex items-center gap-x-2 rounded px-5 py-1.5 text-sm font-semibold transition hover:opacity-75 md:py-2.5 md:px-8 md:text-xl; 24 | } 25 | 26 | .input { 27 | @apply w-full rounded bg-[#333] px-5 py-3.5 placeholder-[gray] outline-none focus:bg-[#454545]; 28 | } 29 | 30 | .modalButton { 31 | @apply flex h-11 w-11 items-center justify-center rounded-full border-2 border-[gray] bg-[#2a2a2a]/60 transition hover:border-white hover:bg-white/10; 32 | } 33 | 34 | .planBox { 35 | @apply relative mx-1.5 flex h-20 w-[calc(100%/3)] cursor-default items-center justify-center rounded-sm bg-[#e50914] font-semibold shadow after:absolute after:top-full after:left-1/2 after:block after:-translate-x-1/2 after:border-8 after:border-b-0 after:border-transparent after:border-t-[#e50914] after:content-[""] md:h-32 lg:mx-8; 36 | } 37 | 38 | /* Table */ 39 | .tableRow { 40 | @apply flex flex-wrap items-center font-medium; 41 | } 42 | 43 | .tableDataTitle { 44 | @apply w-full p-2.5 text-center text-sm font-normal text-white md:w-2/5 md:p-3.5 md:text-left md:text-base; 45 | } 46 | 47 | .tableDataFeature { 48 | @apply w-[calc(100%/3)] p-2.5 text-center md:w-[calc(60%/3)] md:p-3.5; 49 | } 50 | 51 | .membershipLink { 52 | @apply cursor-pointer text-blue-500 hover:underline; 53 | } 54 | 55 | /* MUI Menu */ 56 | .menu { 57 | @apply md:hidden; 58 | } 59 | 60 | .menu .MuiPaper-root { 61 | @apply !absolute !left-0 !rounded-none !border !border-[gray] !bg-black !text-white; 62 | } 63 | 64 | .menu .MuiList-root { 65 | @apply !p-0; 66 | } 67 | 68 | .menu .MuiMenuItem-root { 69 | @apply !block !w-72 !py-3.5 !text-center !text-sm !font-light !text-[#b3b3b3] !transition !duration-200 first:cursor-default first:!font-normal first:!text-white hover:!bg-[#11100F]; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /components/Membership.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import useAuth from '../hooks/useAuth' 3 | import useSubscription from '../hooks/useSubscription' 4 | import { goToBillingPortal } from '../lib/stripe' 5 | import Loader from './Loader' 6 | 7 | function Membership() { 8 | const { user } = useAuth() 9 | const subscription = useSubscription(user) 10 | const [isBillingLoading, setBillingLoading] = useState(false) 11 | 12 | const manageSubscription = () => { 13 | if (subscription) { 14 | setBillingLoading(true) 15 | goToBillingPortal() 16 | } 17 | } 18 | 19 | return ( 20 |
21 |
22 |

Membership & Billing

23 | 34 |
35 | 36 |
37 |
38 |
39 |

{user?.email}

40 |

Password: *********

41 |
42 |
43 |

Change email

44 |

Change password

45 |
46 |
47 | 48 |
49 |
50 |

51 | {subscription?.cancel_at_period_end 52 | ? 'Your membership will end on ' 53 | : 'Your next billing date is '} 54 | {subscription?.current_period_end} 55 |

56 |
57 |
58 |

Manage payment info

59 |

Add backup payment method

60 |

Billing Details

61 |

Change billing day

62 |
63 |
64 |
65 |
66 | ) 67 | } 68 | 69 | export default Membership 70 | -------------------------------------------------------------------------------- /components/Table.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from '@heroicons/react/outline' 2 | import { Product } from '@stripe/firestore-stripe-payments' 3 | 4 | interface Props { 5 | products: Product[] 6 | selectedPlan: Product | null 7 | } 8 | 9 | function Table({ products, selectedPlan }: Props) { 10 | return ( 11 | 12 | 13 | 14 | 15 | {products.map((product) => ( 16 | 26 | ))} 27 | 28 | 29 | 30 | 31 | {products.map((product) => ( 32 | 42 | ))} 43 | 44 | 45 | 46 | 47 | {products.map((product) => ( 48 | 58 | ))} 59 | 60 | 61 | 62 | 65 | {products.map((product) => ( 66 | 78 | ))} 79 | 80 | 81 |
Monthly price 24 | AED{product.prices[0].unit_amount! / 100} 25 |
Video quality 40 | {product.metadata.videoQuality} 41 |
Resolution 56 | {product.metadata.resolution} 57 |
63 | Watch on your TV, computer, mobile phone and tablet 64 | 74 | {product.metadata.portability === 'true' && ( 75 | 76 | )} 77 |
82 | ) 83 | } 84 | 85 | export default Table 86 | -------------------------------------------------------------------------------- /hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createUserWithEmailAndPassword, 3 | onAuthStateChanged, 4 | signInWithEmailAndPassword, 5 | signOut, 6 | User, 7 | } from 'firebase/auth' 8 | 9 | import { useRouter } from 'next/router' 10 | import { createContext, useContext, useEffect, useMemo, useState } from 'react' 11 | import { auth } from '../firebase' 12 | 13 | interface IAuth { 14 | user: User | null 15 | signUp: (email: string, password: string) => Promise 16 | signIn: (email: string, password: string) => Promise 17 | logout: () => Promise 18 | error: string | null 19 | loading: boolean 20 | } 21 | 22 | const AuthContext = createContext({ 23 | user: null, 24 | signUp: async () => {}, 25 | signIn: async () => {}, 26 | logout: async () => {}, 27 | error: null, 28 | loading: false, 29 | }) 30 | 31 | interface AuthProviderProps { 32 | children: React.ReactNode 33 | } 34 | 35 | export const AuthProvider = ({ children }: AuthProviderProps) => { 36 | const [loading, setLoading] = useState(false) 37 | const [user, setUser] = useState(null) 38 | const [error, setError] = useState(null) 39 | const [initialLoading, setInitialLoading] = useState(true) 40 | const router = useRouter() 41 | 42 | // Persisting the user 43 | useEffect( 44 | () => 45 | onAuthStateChanged(auth, (user) => { 46 | if (user) { 47 | // Logged in... 48 | setUser(user) 49 | setLoading(false) 50 | } else { 51 | // Not logged in... 52 | setUser(null) 53 | setLoading(true) 54 | router.push('/login') 55 | } 56 | 57 | setInitialLoading(false) 58 | }), 59 | [auth] 60 | ) 61 | 62 | const signUp = async (email: string, password: string) => { 63 | setLoading(true) 64 | 65 | await createUserWithEmailAndPassword(auth, email, password) 66 | .then((userCredential) => { 67 | setUser(userCredential.user) 68 | router.push('/') 69 | setLoading(false) 70 | }) 71 | .catch((error) => alert(error.message)) 72 | .finally(() => setLoading(false)) 73 | } 74 | 75 | const signIn = async (email: string, password: string) => { 76 | setLoading(true) 77 | 78 | await signInWithEmailAndPassword(auth, email, password) 79 | .then((userCredential) => { 80 | setUser(userCredential.user) 81 | router.push('/') 82 | setLoading(false) 83 | }) 84 | .catch((error) => alert(error.message)) 85 | .finally(() => setLoading(false)) 86 | } 87 | 88 | const logout = async () => { 89 | setLoading(true) 90 | 91 | signOut(auth) 92 | .then(() => { 93 | setUser(null) 94 | }) 95 | .catch((error) => alert(error.message)) 96 | .finally(() => setLoading(false)) 97 | } 98 | 99 | const memoedValue = useMemo( 100 | () => ({ 101 | user, 102 | signUp, 103 | signIn, 104 | loading, 105 | logout, 106 | error, 107 | }), 108 | [user, loading] 109 | ) 110 | 111 | return ( 112 | 113 | {!initialLoading && children} 114 | 115 | ) 116 | } 117 | 118 | export default function useAuth() { 119 | return useContext(AuthContext) 120 | } 121 | -------------------------------------------------------------------------------- /pages/login.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Image from 'next/image' 3 | import { useState } from 'react' 4 | import { SubmitHandler, useForm } from 'react-hook-form' 5 | import useAuth from '../hooks/useAuth' 6 | 7 | interface Inputs { 8 | email: string 9 | password: string 10 | } 11 | 12 | function Login() { 13 | const [login, setLogin] = useState(false) 14 | const { signIn, signUp } = useAuth() 15 | 16 | const { 17 | register, 18 | handleSubmit, 19 | formState: { errors }, 20 | } = useForm() 21 | 22 | const onSubmit: SubmitHandler = async ({ email, password }) => { 23 | if (login) { 24 | await signIn(email, password) 25 | } else { 26 | await signUp(email, password) 27 | } 28 | } 29 | 30 | return ( 31 |
32 | 33 | Netflix 34 | 35 | 36 | 42 | 43 | 49 | 50 |
54 |

Sign In

55 |
56 | 69 | 82 |
83 | 84 | 90 | 91 |
92 | New to Netflix?{' '} 93 | 100 |
101 |
102 |
103 | ) 104 | } 105 | 106 | export default Login 107 | -------------------------------------------------------------------------------- /pages/account.tsx: -------------------------------------------------------------------------------- 1 | import { getProducts, Product } from '@stripe/firestore-stripe-payments' 2 | import { GetStaticProps } from 'next' 3 | import Head from 'next/head' 4 | import Link from 'next/link' 5 | import Membership from '../components/Membership' 6 | import useAuth from '../hooks/useAuth' 7 | import useSubscription from '../hooks/useSubscription' 8 | import payments from '../lib/stripe' 9 | 10 | interface Props { 11 | products: Product[] 12 | } 13 | 14 | function Account({ products }: Props) { 15 | console.log(products) 16 | const { user, logout } = useAuth() 17 | const subscription = useSubscription(user) 18 | 19 | return ( 20 |
21 | 22 | Account Settings - Netflix 23 | 24 | 25 | 26 |
27 | 28 | 34 | 35 | 36 | 41 | 42 |
43 | 44 |
45 |
46 |

Account

47 |
48 | 49 |

50 | Member since {subscription?.created} 51 |

52 |
53 |
54 | 55 | 56 | 57 |
58 |

Plan Details

59 | {/* Find the current plan */} 60 |
61 | { 62 | products.filter( 63 | (product) => product.id === subscription?.product 64 | )[0]?.name 65 | } 66 |
67 |

68 | Change plan 69 |

70 |
71 | 72 |
73 |

Settings

74 |

78 | Sign out of all devices 79 |

80 |
81 |
82 |
83 | ) 84 | } 85 | 86 | export default Account 87 | 88 | export const getStaticProps: GetStaticProps = async () => { 89 | const products = await getProducts(payments, { 90 | includePrices: true, 91 | activeOnly: true, 92 | }) 93 | .then((res) => res) 94 | .catch((error) => console.log(error.message)) 95 | 96 | return { 97 | props: { 98 | products, 99 | }, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /components/Plans.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from '@heroicons/react/outline' 2 | import { Product } from '@stripe/firestore-stripe-payments' 3 | import Head from 'next/head' 4 | import Link from 'next/link' 5 | import { useState } from 'react' 6 | import useAuth from '../hooks/useAuth' 7 | import { loadCheckout } from '../lib/stripe' 8 | import Loader from './Loader' 9 | import Table from './Table' 10 | 11 | interface Props { 12 | products: Product[] 13 | } 14 | 15 | function Plans({ products }: Props) { 16 | const { logout, user } = useAuth() 17 | const [selectedPlan, setSelectedPlan] = useState(products[2]) 18 | const [isBillingLoading, setBillingLoading] = useState(false) 19 | 20 | const subscribeToPlan = () => { 21 | if (!user) return 22 | 23 | loadCheckout(selectedPlan?.prices[0].id!) 24 | setBillingLoading(true) 25 | } 26 | 27 | return ( 28 |
29 | 30 | Netflix 31 | 32 | 33 | 34 |
35 | 36 | Netflix 43 | 44 | 50 |
51 | 52 |
53 |

54 | Choose the plan that's right for you 55 |

56 |
    57 |
  • 58 | Watch all you want. 59 | Ad-free. 60 |
  • 61 |
  • 62 | Recommendations 63 | just for you. 64 |
  • 65 |
  • 66 | Change or cancel 67 | your plan anytime. 68 |
  • 69 |
70 | 71 |
72 |
73 | {products.map((product) => ( 74 |
setSelectedPlan(product)} 80 | > 81 | {product.name} 82 |
83 | ))} 84 |
85 | 86 | 87 | 88 | 101 | 102 | 103 | 104 | ) 105 | } 106 | 107 | export default Plans 108 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { getProducts, Product } from '@stripe/firestore-stripe-payments' 2 | import Head from 'next/head' 3 | import { useRecoilValue } from 'recoil' 4 | import { modalState, movieState } from '../atoms/modalAtom' 5 | import Banner from '../components/Banner' 6 | import Header from '../components/Header' 7 | import Modal from '../components/Modal' 8 | import Plans from '../components/Plans' 9 | import Row from '../components/Row' 10 | import useAuth from '../hooks/useAuth' 11 | import useList from '../hooks/useList' 12 | import useSubscription from '../hooks/useSubscription' 13 | import payments from '../lib/stripe' 14 | import { Movie } from '../typings' 15 | import requests from '../utils/requests' 16 | 17 | interface Props { 18 | netflixOriginals: Movie[] 19 | trendingNow: Movie[] 20 | topRated: Movie[] 21 | actionMovies: Movie[] 22 | comedyMovies: Movie[] 23 | horrorMovies: Movie[] 24 | romanceMovies: Movie[] 25 | documentaries: Movie[] 26 | products: Product[] 27 | } 28 | 29 | const Home = ({ 30 | netflixOriginals, 31 | actionMovies, 32 | comedyMovies, 33 | documentaries, 34 | horrorMovies, 35 | romanceMovies, 36 | topRated, 37 | trendingNow, 38 | products, 39 | }: Props) => { 40 | const { loading, user } = useAuth() 41 | const showModal = useRecoilValue(modalState) 42 | const subscription = useSubscription(user) 43 | const movie = useRecoilValue(movieState) 44 | const list = useList(user?.uid) 45 | 46 | if (loading || subscription === null) return null 47 | 48 | if (!subscription) return 49 | 50 | return ( 51 |
56 | 57 | Home - Netflix 58 | 59 | 60 | 61 |
62 |
63 | 64 |
65 | 66 | 67 | 68 | {/* My List Component */} 69 | {list.length > 0 && } 70 | 71 | 72 | 73 | 74 |
75 |
76 | {showModal && } 77 |
78 | ) 79 | } 80 | 81 | export default Home 82 | 83 | export const getServerSideProps = async () => { 84 | const products = await getProducts(payments, { 85 | includePrices: true, 86 | activeOnly: true, 87 | }) 88 | .then((res) => res) 89 | .catch((error) => console.log(error.message)) 90 | 91 | const [ 92 | netflixOriginals, 93 | trendingNow, 94 | topRated, 95 | actionMovies, 96 | comedyMovies, 97 | horrorMovies, 98 | romanceMovies, 99 | documentaries, 100 | ] = await Promise.all([ 101 | fetch(requests.fetchNetflixOriginals).then((res) => res.json()), 102 | fetch(requests.fetchTrending).then((res) => res.json()), 103 | fetch(requests.fetchTopRated).then((res) => res.json()), 104 | fetch(requests.fetchActionMovies).then((res) => res.json()), 105 | fetch(requests.fetchComedyMovies).then((res) => res.json()), 106 | fetch(requests.fetchHorrorMovies).then((res) => res.json()), 107 | fetch(requests.fetchRomanceMovies).then((res) => res.json()), 108 | fetch(requests.fetchDocumentaries).then((res) => res.json()), 109 | ]) 110 | 111 | return { 112 | props: { 113 | netflixOriginals: netflixOriginals.results, 114 | trendingNow: trendingNow.results, 115 | topRated: topRated.results, 116 | actionMovies: actionMovies.results, 117 | comedyMovies: comedyMovies.results, 118 | horrorMovies: horrorMovies.results, 119 | romanceMovies: romanceMovies.results, 120 | documentaries: documentaries.results, 121 | products, 122 | }, 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CheckIcon, 3 | PlusIcon, 4 | ThumbUpIcon, 5 | VolumeOffIcon, 6 | XIcon, 7 | } from '@heroicons/react/outline' 8 | import { VolumeUpIcon } from '@heroicons/react/solid' 9 | import MuiModal from '@mui/material/Modal' 10 | import { 11 | collection, 12 | deleteDoc, 13 | doc, 14 | DocumentData, 15 | onSnapshot, 16 | setDoc, 17 | } from 'firebase/firestore' 18 | import { useEffect, useState } from 'react' 19 | import toast, { Toaster } from 'react-hot-toast' 20 | import { FaPlay } from 'react-icons/fa' 21 | import ReactPlayer from 'react-player/lazy' 22 | import { useRecoilState } from 'recoil' 23 | import { modalState, movieState } from '../atoms/modalAtom' 24 | import { db } from '../firebase' 25 | import useAuth from '../hooks/useAuth' 26 | import { Element, Genre, Movie } from '../typings' 27 | 28 | function Modal() { 29 | const [showModal, setShowModal] = useRecoilState(modalState) 30 | const [movie, setMovie] = useRecoilState(movieState) 31 | const [trailer, setTrailer] = useState('') 32 | const [genres, setGenres] = useState([]) 33 | const [muted, setMuted] = useState(true) 34 | const { user } = useAuth() 35 | const [addedToList, setAddedToList] = useState(false) 36 | const [movies, setMovies] = useState([]) 37 | 38 | const toastStyle = { 39 | background: 'white', 40 | color: 'black', 41 | fontWeight: 'bold', 42 | fontSize: '16px', 43 | padding: '15px', 44 | borderRadius: '9999px', 45 | maxWidth: '1000px', 46 | } 47 | 48 | useEffect(() => { 49 | if (!movie) return 50 | 51 | async function fetchMovie() { 52 | const data = await fetch( 53 | `https://api.themoviedb.org/3/${ 54 | movie?.media_type === 'tv' ? 'tv' : 'movie' 55 | }/${movie?.id}?api_key=${ 56 | process.env.NEXT_PUBLIC_API_KEY 57 | }&language=en-US&append_to_response=videos` 58 | ) 59 | .then((response) => response.json()) 60 | .catch((err) => console.log(err.message)) 61 | 62 | if (data?.videos) { 63 | const index = data.videos.results.findIndex( 64 | (element: Element) => element.type === 'Trailer' 65 | ) 66 | setTrailer(data.videos?.results[index]?.key) 67 | } 68 | if (data?.genres) { 69 | setGenres(data.genres) 70 | } 71 | } 72 | 73 | fetchMovie() 74 | }, [movie]) 75 | 76 | // Find all the movies in the user's list 77 | useEffect(() => { 78 | if (user) { 79 | return onSnapshot( 80 | collection(db, 'customers', user.uid, 'myList'), 81 | (snapshot) => setMovies(snapshot.docs) 82 | ) 83 | } 84 | }, [db, movie?.id]) 85 | 86 | // Check if the movie is already in the user's list 87 | useEffect( 88 | () => 89 | setAddedToList( 90 | movies.findIndex((result) => result.data().id === movie?.id) !== -1 91 | ), 92 | [movies] 93 | ) 94 | 95 | const handleList = async () => { 96 | if (addedToList) { 97 | await deleteDoc( 98 | doc(db, 'customers', user!.uid, 'myList', movie?.id.toString()!) 99 | ) 100 | 101 | toast( 102 | `${movie?.title || movie?.original_name} has been removed from My List`, 103 | { 104 | duration: 8000, 105 | style: toastStyle, 106 | } 107 | ) 108 | } else { 109 | await setDoc( 110 | doc(db, 'customers', user!.uid, 'myList', movie?.id.toString()!), 111 | { ...movie } 112 | ) 113 | 114 | toast( 115 | `${movie?.title || movie?.original_name} has been added to My List`, 116 | { 117 | duration: 8000, 118 | style: toastStyle, 119 | } 120 | ) 121 | } 122 | } 123 | 124 | const handleClose = () => { 125 | setShowModal(false) 126 | } 127 | 128 | console.log(trailer) 129 | 130 | return ( 131 | 136 | <> 137 | 138 | 144 | 145 |
146 | 154 |
155 |
156 | 160 | 161 | 168 | 169 | 172 |
173 | 180 |
181 |
182 | 183 |
184 |
185 |
186 |

187 | {movie!.vote_average * 10}% Match 188 |

189 |

190 | {movie?.release_date || movie?.first_air_date} 191 |

192 |
193 | HD 194 |
195 |
196 | 197 |
198 |

{movie?.overview}

199 |
200 |
201 | Genres: 202 | {genres.map((genre) => genre.name).join(', ')} 203 |
204 | 205 |
206 | Original language: 207 | {movie?.original_language} 208 |
209 | 210 |
211 | Total votes: 212 | {movie?.vote_count} 213 |
214 |
215 |
216 |
217 |
218 | 219 |
220 | ) 221 | } 222 | 223 | export default Modal 224 | --------------------------------------------------------------------------------