├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (home) │ ├── categories │ │ └── page.tsx │ ├── category │ │ └── [category] │ │ │ └── page.tsx │ ├── edit │ │ └── [productId] │ │ │ ├── delete-product.tsx │ │ │ ├── edit-product-form.tsx │ │ │ ├── edit-product.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ ├── my-products │ │ └── page.tsx │ ├── my-upvoted │ │ └── page.tsx │ ├── new-product │ │ ├── layout.tsx │ │ └── page.tsx │ ├── page.tsx │ └── settings │ │ ├── manage-billing.tsx │ │ └── page.tsx ├── admin │ ├── activate-product-modal-content.tsx │ ├── page.tsx │ ├── pending-products.tsx │ └── reject-product-modal-content.tsx ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── stripe │ │ └── route.ts │ └── uploadthing │ │ ├── core.ts │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── not-found.tsx └── product │ └── [slug] │ ├── go-to-website.tsx │ ├── layout.tsx │ └── page.tsx ├── auth.config.ts ├── auth.ts ├── components.json ├── components ├── active-products.tsx ├── carousel-component.tsx ├── images-uploader.tsx ├── logo-uploader.tsx ├── navbar │ ├── auth-content.tsx │ ├── avatar.tsx │ ├── logo.tsx │ ├── menu.tsx │ ├── menus │ │ ├── about-menu.tsx │ │ ├── community-menu.tsx │ │ └── launches-menu.tsx │ ├── navbar.tsx │ ├── notification-icon.tsx │ ├── search.tsx │ ├── sign-in-button.tsx │ ├── sign-up-button.tsx │ └── submit.tsx ├── overview-chart.tsx ├── product-item.tsx ├── product-modal-content.tsx ├── recent-activity.tsx ├── share-modal-content.tsx ├── spinner.tsx ├── ui │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── dropdown-menu.tsx │ ├── modals │ │ ├── activate-product-modal.tsx │ │ ├── edit-product-modal.tsx │ │ ├── modal.tsx │ │ ├── product-modal.tsx │ │ ├── reject-product-modal.tsx │ │ ├── share-product-modal.tsx │ │ └── upgrade-membership-modal.tsx │ ├── popover.tsx │ ├── separator.tsx │ ├── sheet.tsx │ └── sonner.tsx └── upgrade-membership.tsx ├── lib ├── db.ts ├── server-actions.ts ├── stripe.ts ├── uploadthing.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma └── schema.prisma ├── public ├── images │ └── start-up-14.png ├── logo │ ├── discord-logo.png │ ├── logo.png │ ├── small-logo.png │ └── twitter-logo.png ├── next.svg └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | AUTH_SECRET=secret 2 | 3 | GITHUB_ID= 4 | GITHUB_SECRET= 5 | 6 | GOOGLE_ID= 7 | GOOGLE_SECRET= 8 | 9 | 10 | DATABASE_URL= 11 | 12 | UPLOADTHING_SECRET= 13 | UPLOADTHING_APP_ID= 14 | 15 | STRIPE_WEBHOOK_SIGNING_SECRET= 16 | STRIPE_SECRET_KEY= 17 | 18 | ADMIN_USERNAME=admin 19 | ADMIN_PASSWORD=password -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /app/(home)/categories/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCategories } from "@/lib/server-actions"; 2 | import Link from "next/link"; 3 | 4 | const Categories = async () => { 5 | const categories = await getCategories(); 6 | 7 | return ( 8 |
9 |
10 |

Categories

11 |

12 | Discover new products in different categories and find what you need 13 | to make your life easier 14 |

15 |
16 | 17 |
18 |
19 | {categories.map((category: any) => ( 20 | 33 |
34 |

{category.name}

35 |

36 | View all products 37 |

38 |
39 | 40 | ))} 41 |
42 |
43 |
44 | ); 45 | }; 46 | 47 | export default Categories; 48 | -------------------------------------------------------------------------------- /app/(home)/category/[category]/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Breadcrumb, 3 | BreadcrumbItem, 4 | BreadcrumbLink, 5 | BreadcrumbList, 6 | BreadcrumbPage, 7 | BreadcrumbSeparator, 8 | } from "@/components/ui/breadcrumb"; 9 | 10 | import { getProductsByCategoryName } from "@/lib/server-actions"; 11 | import Image from "next/image"; 12 | import Link from "next/link"; 13 | 14 | interface IParams { 15 | category: string; 16 | } 17 | 18 | const CategoryPage: React.FC<{ params: IParams }> = async ({ params }) => { 19 | const capitalizedCategory = 20 | params.category.charAt(0).toUpperCase() + params.category.slice(1); 21 | 22 | const products = await getProductsByCategoryName(capitalizedCategory); 23 | 24 | return ( 25 |
26 | 27 | 28 | 29 | Home 30 | 31 | 32 | 33 | Categories 34 | 35 | 36 | 37 | {capitalizedCategory} 38 | 39 | 40 | 41 | 42 |

{capitalizedCategory}

43 |

44 | Check out whats's going on in the {capitalizedCategory}! Discover 45 | new products 46 |

47 | 48 |
49 | {products.map((product: any) => ( 50 | 55 | logo 62 |
63 |

{product.name}

64 |

{product.headline}

65 |
66 | 67 | ))} 68 |
69 |
70 | ); 71 | }; 72 | 73 | export default CategoryPage; 74 | -------------------------------------------------------------------------------- /app/(home)/edit/[productId]/delete-product.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Modal from "@/components/ui/modals/modal"; 4 | import { deleteProduct } from "@/lib/server-actions"; 5 | import { useRouter } from "next/navigation"; 6 | import { useState } from "react"; 7 | import { PiStorefront, PiTrash } from "react-icons/pi"; 8 | 9 | interface DeleteProductProps { 10 | productId: string; 11 | } 12 | 13 | const DeleteProduct: React.FC = ({ productId }) => { 14 | const router = useRouter(); 15 | 16 | const [confirmationInput, setConfirmationInput] = useState(""); 17 | const [isDeleteButtonEnabled, setIsDeleteButtonEnabled] = useState(false); 18 | 19 | const handleConfirmationInputChange = (e: any) => { 20 | const inputText = e.target.value.toLowerCase(); 21 | setConfirmationInput(inputText); 22 | setIsDeleteButtonEnabled(inputText === "delete"); 23 | }; 24 | 25 | const handleCancel = () => { 26 | setDeleteProductModalVisible(false); 27 | }; 28 | 29 | const [deleteProductModalVisible, setDeleteProductModalVisible] = 30 | useState(false); 31 | 32 | const handleDeleteProductClick = () => { 33 | setDeleteProductModalVisible(true); 34 | }; 35 | 36 | const handleConfirmDelete = () => { 37 | if (confirmationInput === "delete") { 38 | setTimeout(async () => { 39 | try { 40 | await deleteProduct(productId); 41 | router.push("/my-products"); 42 | router.refresh(); 43 | } catch (error) { 44 | console.error(error); 45 | } 46 | }, 3000); 47 | } 48 | }; 49 | 50 | return ( 51 | <> 52 | 59 | 60 | 64 |
65 | 69 |

Delete Product

70 | 71 |

72 | We're sorry to see you go. Once your product is deleted, all of 73 | your content will be permanently gone, including your products and 74 | product settings. 75 |

76 | 77 |

78 | This action cannot be undone. This will permanently delete your 79 | product and all of your content. 80 |

81 | 82 |

To confirm deletion, type “delete” below:

83 | 84 | 90 | 91 |
92 | 100 | 111 |
112 |
113 |
114 | 115 | ); 116 | }; 117 | 118 | export default DeleteProduct; 119 | -------------------------------------------------------------------------------- /app/(home)/edit/[productId]/edit-product-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ImagesUploader } from "@/components/images-uploader"; 4 | import { LogoUploader } from "@/components/logo-uploader"; 5 | import { updateProduct } from "@/lib/server-actions"; 6 | import Image from "next/image"; 7 | import { useRouter } from "next/navigation"; 8 | import { useEffect, useState } from "react"; 9 | import { PiCheckCircle, PiFlag, PiPencilLine, PiXCircle } from "react-icons/pi"; 10 | import { toast } from "sonner"; 11 | 12 | interface EditProductFormProps { 13 | product: any; 14 | } 15 | 16 | const EditProductForm: React.FC = ({ product }) => { 17 | const [isEditingLogo, setIsEditingLogo] = useState(false); 18 | const [uploadedLogoUrl, setUploadedLogoUrl] = useState(""); 19 | const [isEditingProductImages, setIsEditingProductImages] = useState(false); 20 | const [uploadedProductImages, setUploadedProductImages] = useState( 21 | [] 22 | ); 23 | 24 | const router = useRouter(); 25 | 26 | const [name, setName] = useState(product.name); 27 | const [headline, setHeadline] = useState(product.headline); 28 | const [description, setDescription] = useState(product.description); 29 | const [releaseDate, setReleaseDate] = useState(product.releaseDate); 30 | const [website, setWebsite] = useState(product.website); 31 | const [twitter, setTwitter] = useState(product.twitter); 32 | const [discord, setDiscord] = useState(product.discord); 33 | const [categories, setCategories] = useState(product.categories); 34 | const [slug, setSlug] = useState(product.slug); 35 | 36 | const handleLogoUpload = (url?: string) => { 37 | if (url) { 38 | setUploadedLogoUrl(url); 39 | setIsEditingLogo(false); 40 | } else { 41 | setIsEditingLogo(true); 42 | } 43 | }; 44 | 45 | const handleProductImagesUpload = (urls: string[]) => { 46 | setUploadedProductImages(urls); 47 | setIsEditingProductImages(false); 48 | }; 49 | 50 | const handleNameChange = (e: any) => { 51 | const productName = e.target.value; 52 | const truncatedName = productName.slice(0, 30); 53 | setName(truncatedName); 54 | }; 55 | 56 | useEffect(() => { 57 | // Update slug when name changes 58 | const truncatedName = name.slice(0, 30); 59 | const slugValue = truncatedName 60 | .toLowerCase() 61 | .replace(/\s+/g, "-") 62 | .replace(/\./g, "-"); 63 | setSlug(slugValue); 64 | }, [name]); // Trigger effect when name changes 65 | 66 | const onSave = async () => { 67 | try { 68 | await updateProduct(product.id, { 69 | name, 70 | headline, 71 | description, 72 | releaseDate, 73 | website, 74 | slug, 75 | twitter, 76 | discord, 77 | category: categories, 78 | logo: uploadedLogoUrl || product.logo, 79 | images: 80 | uploadedProductImages.length > 0 81 | ? uploadedProductImages 82 | : product.images.map((image: any) => image.url), 83 | }); 84 | toast( 85 | <> 86 |
87 | 88 |
89 | Product updated successfully. 90 |
91 |
92 | , 93 | { position: "top-center" } 94 | ); 95 | router.refresh(); 96 | } catch (error: any) { 97 | toast( 98 | <> 99 |
100 | 101 |
102 | There was an error updating the product 103 | {error.message} 104 |
105 |
106 | , 107 | { position: "top-center" } 108 | ); 109 | } 110 | }; 111 | 112 | return ( 113 |
114 |
115 | 116 |

Edit Product

117 |
118 | 119 |
120 | 121 |
122 | This is the product form. You can update the product details here. If 123 | your product is currently live, and you make changes to the product 124 | details. It will delist the product from the marketplace until it is 125 | reviewed and approved by the admin. 126 |
127 |
128 | 129 |
130 |
131 |

Logo

132 | {isEditingLogo ? ( 133 |
134 | 138 | 144 |
145 | ) : ( 146 |
147 | logo 154 | 160 |
161 | )} 162 |
163 | 164 |
165 |

Product Name

166 | 172 |
173 | 174 |
175 |
Website
176 | setWebsite(e.target.value)} // Update state variable on change 181 | /> 182 |
183 | 184 |
185 |
Release Date
186 | setReleaseDate(e.target.value)} 191 | /> 192 |
193 | 194 |
195 |
Headline
196 | 75 |
76 | 77 | 85 |
86 |
87 | ); 88 | }; 89 | 90 | export default RejectProductModalContent; 91 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth" 2 | export const { GET, POST } = handlers -------------------------------------------------------------------------------- /app/api/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | 3 | import Stripe from "stripe"; 4 | 5 | import { db } from "@/lib/db"; 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, { 8 | apiVersion: "2024-04-10", 9 | }); 10 | const webhookSigningSecret = process.env 11 | .STRIPE_WEBHOOK_SIGNING_SECRET as string; 12 | 13 | const handlePremiumSubscription = async (event: Stripe.Event) => { 14 | const session = event.data.object as Stripe.Checkout.Session; 15 | const email = session.customer_email; 16 | 17 | //find the user by the email 18 | 19 | if (!email) { 20 | return; 21 | } 22 | 23 | // find the user by the email and then update the isPremium field to true 24 | 25 | const user = await db.user.findFirst({ 26 | where: { 27 | email: email, 28 | }, 29 | }); 30 | 31 | if (user) { 32 | await db.user.update({ 33 | where: { id: user.id }, 34 | data: { isPremium: true }, 35 | }); 36 | console.log(`✅ User ${email} updated to premium.`); 37 | } else { 38 | console.log(`❌ User ${email} not found.`); 39 | } 40 | }; 41 | 42 | 43 | const handleCancelledSubscription = async (event: Stripe.Event) => { 44 | const subscription = event.data.object as Stripe.Subscription; 45 | const customerId = subscription.customer as string; 46 | 47 | try { 48 | const customerResponse = await stripe.customers.retrieve(customerId); 49 | 50 | if (!("email" in customerResponse)) { 51 | console.log( 52 | `❌ Customer not found or has been deleted for customer ID ${customerId}.` 53 | ); 54 | return; 55 | } 56 | 57 | const customerEmail = customerResponse.email; 58 | 59 | if (!customerEmail) { 60 | console.log(`❌ Customer email not found for customer ID ${customerId}.`); 61 | return; 62 | } 63 | 64 | // Find user by email and set isPremium to false 65 | const user = await db.user.findFirst({ 66 | where: { email: customerEmail }, 67 | }); 68 | 69 | if (user) { 70 | await db.user.update({ 71 | where: { id: user.id }, 72 | data: { isPremium: false }, 73 | }); 74 | console.log(`✅ User ${customerEmail} updated to non-premium.`); 75 | } else { 76 | console.log(`❌ User with email ${customerEmail} not found.`); 77 | } 78 | } catch (error: any) { 79 | console.error(`❌ Error handling cancelled subscription: ${error.message}`); 80 | } 81 | }; 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | export async function POST(req: NextRequest) { 91 | const body = await req.text(); 92 | const signature = req.headers.get("stripe-signature") as string; 93 | 94 | let event: Stripe.Event; 95 | 96 | try { 97 | event = stripe.webhooks.constructEvent( 98 | body, 99 | signature, 100 | webhookSigningSecret 101 | ); 102 | } catch (error: any) { 103 | console.log(`❌ Error message: ${error.message}`); 104 | return new NextResponse(`❌ Webhook Error: ${error.message}`, { 105 | status: 400, 106 | }); 107 | } 108 | 109 | console.log(`🔔 Webhook received: ${event.id}: ${event.type}`); 110 | 111 | // handle create subscription 112 | 113 | if (event.type === "checkout.session.completed") { 114 | await handlePremiumSubscription(event); 115 | } 116 | 117 | // Handle Cancelled subscription 118 | if (event.type === "customer.subscription.deleted") { 119 | await handleCancelledSubscription(event); 120 | } 121 | 122 | return new NextResponse(null, { status: 200 }); 123 | } 124 | -------------------------------------------------------------------------------- /app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createUploadthing, type FileRouter } from "uploadthing/next"; 3 | 4 | 5 | const f = createUploadthing(); 6 | 7 | 8 | 9 | export const ourFileRouter = { 10 | productLogo: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } }) 11 | .middleware(async ({ req }) => { 12 | return { ...req }; 13 | 14 | 15 | }) 16 | .onUploadComplete(() => {}), 17 | 18 | productImages: f({ image: { maxFileSize: "4MB", maxFileCount: 5 } }) 19 | .middleware(async ({ req }) => { 20 | return { ...req }; 21 | }) 22 | .onUploadComplete(() => {}), 23 | }; 24 | 25 | 26 | 27 | 28 | export type OurFileRouter = typeof ourFileRouter; -------------------------------------------------------------------------------- /app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from "uploadthing/next"; 2 | 3 | import { ourFileRouter } from "./core"; 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createRouteHandler({ 7 | router: ourFileRouter, 8 | 9 | // Apply an (optional) custom config: 10 | // config: { ... }, 11 | }); -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iggy-tech/product-hunt-clone-final/85b3a0fa123b4a722d6ab963bf9a0d4c4e6de1c9/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | 79 | @import '~@uploadthing/react/styles.css' -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { DM_Sans } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Toaster } from "@/components/ui/sonner"; 5 | import { Analytics } from '@vercel/analytics/react'; 6 | 7 | 8 | const font = DM_Sans({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Product Hunt Clone", 12 | description: "A clone of Product Hunt built with Next.js", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 25 | 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | const NotFound = () => { 5 | return (
6 |
7 |
8 | 9 | 404 17 | 18 | 19 |
20 | Ooops! Looks like something went wrong 21 |
22 |
23 | The page you are looking for does not exist 24 |
25 | 26 | 30 | Home Page 31 | 32 |
33 |
34 |
); 35 | } 36 | 37 | export default NotFound; -------------------------------------------------------------------------------- /app/product/[slug]/go-to-website.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | 4 | interface GoToWebsiteProps { 5 | website: string; 6 | } 7 | 8 | 9 | const GoToWebsite: React.FC = ({ 10 | website 11 | }) => { 12 | return ( 13 |
window.open(website, "_blank")} 15 | className="hidden lg:flex hover:underline cursor-pointer" 16 | 17 | > 18 | Go to website 19 |
20 | ); 21 | } 22 | 23 | export default GoToWebsite; -------------------------------------------------------------------------------- /app/product/[slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | import Navbar from "@/components/navbar/navbar"; 3 | import { getNotifications, getProductsByUserId } from "@/lib/server-actions"; 4 | 5 | const ProductPageLayout = async ({ 6 | children 7 | } : Readonly <{ 8 | children: React.ReactNode 9 | }>) => { 10 | 11 | //get the user from the server 12 | 13 | const authenticatedUser = await auth(); 14 | 15 | const notifications = await getNotifications(); 16 | 17 | const products = await getProductsByUserId(authenticatedUser?.user?.id || ""); 18 | 19 | return ( 20 | 21 | 22 | 27 | {children} 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | export default ProductPageLayout; -------------------------------------------------------------------------------- /app/product/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getProductBySlug } from "@/lib/server-actions"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import GoToWebsite from "./go-to-website"; 5 | import CarouselComponent from "@/components/carousel-component"; 6 | 7 | interface IParams { 8 | slug: string; 9 | } 10 | 11 | const ProductPage = async ({ params }: { params: IParams }) => { 12 | const product = await getProductBySlug(params.slug); 13 | 14 | if (!product) { 15 | return
Product not found
; 16 | } 17 | 18 | const productImageUrls = product.images.map((image: any) => image.url); 19 | 20 | console.log(product, "product info"); 21 | 22 | return ( 23 |
24 |
25 |
26 | logo 33 |
34 |

{product.name}

35 |

{product.headline}

36 | 37 |
38 | {product.categories.map((category: any) => ( 39 | 46 |

{category.name}

47 | 48 | ))} 49 |
50 |
51 |
52 | 53 | 54 |
55 | 56 | {product.description && ( 57 |
58 |

{product.description}

59 |
60 | )} 61 | 62 |
63 | 64 |
65 | 66 |

Community Feedback

67 | 68 | {product.comments.length > 0 ? ( 69 |
70 | {product.comments.map((comment: any) => ( 71 |
72 |
73 | profile 80 |
81 |

{comment.user.name}

82 |

{comment.body}

83 |
84 |
85 |
86 | ))} 87 |
88 | ) : ( 89 |
90 |

No comments yet

91 |

92 | Be the first to comment on this product 93 |

94 |
95 | )} 96 |
97 | ); 98 | }; 99 | 100 | export default ProductPage; 101 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import GitHub from "next-auth/providers/github" 2 | import Google from "next-auth/providers/google" 3 | import type { NextAuthConfig } from "next-auth" 4 | 5 | export default { providers: [GitHub, Google] } satisfies NextAuthConfig -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from "@auth/prisma-adapter" 2 | import NextAuth from "next-auth" 3 | import GitHub from "next-auth/providers/github" 4 | import Google from "next-auth/providers/google" 5 | import { db } from "./lib/db" 6 | 7 | export const { auth, handlers, signIn, signOut } = NextAuth({ 8 | adapter : PrismaAdapter(db), 9 | session: { strategy: "jwt" }, 10 | providers: [ 11 | GitHub({ 12 | clientId: process.env.GITHUB_ID, 13 | clientSecret: process.env.GITHUB_SECRET, 14 | }), 15 | 16 | Google({ 17 | clientId: process.env.GOOGLE_ID, 18 | clientSecret: process.env.GOOGLE_SECRET, 19 | }) 20 | 21 | ], 22 | 23 | callbacks: { 24 | session: ({ session, token }) => ({ 25 | ...session, 26 | user: { 27 | ...session.user, 28 | id: token.sub, 29 | }, 30 | } 31 | ), 32 | }, 33 | 34 | 35 | }) -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/active-products.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | import ProductItem from "./product-item"; 3 | 4 | interface ActiveProductsProps { 5 | activeProducts: any; 6 | } 7 | 8 | const ActiveProducts: React.FC = async ({ 9 | activeProducts, 10 | }) => { 11 | const authenticatedUser = await auth(); 12 | 13 | const formattedActiveProducts = activeProducts?.map((product: any) => { 14 | const { 15 | id, 16 | name, 17 | slug, 18 | headline, 19 | description, 20 | logo, 21 | releaseDate, 22 | website, 23 | twitter, 24 | discord, 25 | createdAt, 26 | updatedAt, 27 | userId, 28 | status, 29 | images, 30 | categories, 31 | comments, 32 | upvotes 33 | } = product; 34 | 35 | 36 | 37 | const imageUrls = images.map((image: any) => image.url); 38 | const categoryNames = categories.map((category: any) => category.name); 39 | const commentsCount = comments ? comments.length : 0; 40 | 41 | const commentText = comments ? comments.map((comment: any) => ({ 42 | id: comment.id, 43 | profile: comment.profilePicture, 44 | body : comment.body, 45 | user : comment.user.name, 46 | timestamp : comment.createdAt, 47 | userId : comment.user.id, 48 | name: comment.user.name.toLowerCase().replace(/\s/g, '_'), 49 | 50 | })) : []; 51 | 52 | 53 | const upvotesCount = upvotes ? upvotes.length : 0; 54 | const upvotesData = upvotes.map((upvote: any) => upvote.user.id) 55 | 56 | return { 57 | id, 58 | name, 59 | slug, 60 | headline, 61 | description, 62 | logo, 63 | releaseDate, 64 | website, 65 | twitter, 66 | discord, 67 | createdAt, 68 | updatedAt, 69 | userId, 70 | status, 71 | images: imageUrls, 72 | categories: categoryNames, 73 | commentsLength: commentsCount, 74 | commentData : commentText, 75 | upvoters: upvotesData, 76 | upvotes: upvotesCount, 77 | }; 78 | }); 79 | 80 | console.log(formattedActiveProducts, 'formattedActiveProducts') 81 | 82 | 83 | return ( 84 |
85 |
86 |

All Products

87 |
88 | 89 |
90 | {formattedActiveProducts?.map((product: any) => ( 91 | 96 | ))} 97 | 98 |
99 |
100 | ); 101 | }; 102 | 103 | export default ActiveProducts; 104 | -------------------------------------------------------------------------------- /components/carousel-component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { 4 | Carousel, 5 | CarouselContent, 6 | CarouselItem, 7 | CarouselNext, 8 | CarouselPrevious, 9 | } from "@/components/ui/carousel"; 10 | import Image from "next/image"; 11 | 12 | interface CarouselProps { 13 | productImages: string[]; 14 | } 15 | 16 | const CarouselComponent: React.FC = ({ productImages }) => { 17 | return ( 18 | 28 | 29 | {Array.from({ 30 | length: productImages.length, 31 | }).map((_, index) => ( 32 | 38 | product-image 54 | 55 | ))} 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default CarouselComponent; -------------------------------------------------------------------------------- /components/images-uploader.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { UploadDropzone } from "@/lib/uploadthing"; 4 | import { ourFileRouter } from "@/app/api/uploadthing/core"; 5 | import { toast } from "sonner"; 6 | 7 | interface ImagesUploaderProps { 8 | onChange: (urls: string[]) => void; 9 | endpoint: keyof typeof ourFileRouter; 10 | } 11 | 12 | export const ImagesUploader = ({ onChange, endpoint }: ImagesUploaderProps) => { 13 | const handleUploadComplete = (res: { url: string }[]) => { 14 | const urls = res.map((item) => item.url); 15 | onChange(urls); 16 | }; 17 | 18 | return ( 19 | { 23 | toast(error.message, { position: "top-center" }); 24 | }} 25 | /> 26 | ); 27 | }; -------------------------------------------------------------------------------- /components/logo-uploader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UploadDropzone } from "@/lib/uploadthing"; 4 | import { ourFileRouter } from "@/app/api/uploadthing/core"; 5 | import { toast } from "sonner"; 6 | 7 | interface LogoUploaderProps { 8 | onChange: (url?: string) => void; 9 | endpoint: keyof typeof ourFileRouter; 10 | } 11 | 12 | export const LogoUploader = ({ onChange, endpoint }: LogoUploaderProps) => { 13 | return ( 14 | { 17 | onChange(res?.[0].url); 18 | }} 19 | onUploadError={(error: Error) => { 20 | toast(error.message, { position: "top-center" }); 21 | }} 22 | /> 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /components/navbar/auth-content.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { FaGithub } from "react-icons/fa"; 3 | import { FcGoogle } from "react-icons/fc"; 4 | import { signIn } from "next-auth/react"; 5 | 6 | const AuthContent = () => { 7 | return ( 8 |
9 | logo 16 | 17 |
18 |
19 | See what's new in tech 20 |
21 |
22 | Join our community of friendly folks discovering and sharing the 23 | latest product in tech 24 |
25 |
26 | 27 | 34 | 35 | 42 |
43 | ); 44 | }; 45 | 46 | export default AuthContent; 47 | -------------------------------------------------------------------------------- /components/navbar/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuContent, 4 | DropdownMenuItem, 5 | DropdownMenuLabel, 6 | DropdownMenuSeparator, 7 | DropdownMenuTrigger, 8 | } from "@/components/ui/dropdown-menu"; 9 | import Image from "next/image"; 10 | import Link from "next/link"; 11 | import { PiGear, PiHeart, PiPackage } from "react-icons/pi"; 12 | import { signOut } from "next-auth/react"; 13 | 14 | 15 | interface AvatarProps { 16 | authenticatedUser?: any; 17 | } 18 | 19 | const Avatar: React.FC = ({ authenticatedUser }) => { 20 | 21 | 22 | 23 | const handleMyUpvotes = () => { 24 | window.location.href = "/my-upvoted"; 25 | } 26 | 27 | 28 | 29 | return ( 30 |
31 | 32 | 33 | avatar 40 | 41 | 42 | 43 | 47 | 48 | My Products 49 | 50 | 51 | 52 |
56 | 57 | Upvoted 58 |
59 |
60 | 61 | 65 | 66 | Settings 67 | 68 | 69 | 70 |
signOut()} 72 | > 73 | Log out 74 |
75 |
76 |
77 |
78 |
79 | ); 80 | }; 81 | 82 | export default Avatar; 83 | -------------------------------------------------------------------------------- /components/navbar/logo.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | 5 | const Logo = () => { 6 | return ( 7 |
8 | 9 | logo 16 | 17 | 18 | 19 | logo 25 | 26 | 27 | 28 | 29 |
); 30 | } 31 | 32 | export default Logo; -------------------------------------------------------------------------------- /components/navbar/menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import LaunchesMenu from "./menus/launches-menu"; 5 | import Link from "next/link"; 6 | import CommunityMenu from "./menus/community-menu"; 7 | import AboutMenu from "./menus/about-menu"; 8 | 9 | const Menu = () => { 10 | const [showLaunchesMenu, setShowLaunchesMenu] = useState(false); 11 | const [showCommunityMenu, setShowCommunityMenu] = useState(false); 12 | const [showAboutMenu, setShowAboutMenu] = useState(false); 13 | 14 | return ( 15 |
16 |
17 |
setShowLaunchesMenu(true)} 19 | onMouseLeave={() => setShowLaunchesMenu(false)} 20 | className="hover:text-[#ff6154] py-4" 21 | > 22 | Launches {showLaunchesMenu && } 23 |
24 | 25 | 26 | Categories 27 | 28 | 29 |
setShowCommunityMenu(true)} 31 | onMouseLeave={() => setShowCommunityMenu(false)} 32 | className="hover:text-[#ff6154] py-4" 33 | 34 | > 35 | Community {showCommunityMenu && } 36 |
37 | 38 |
Advertise
39 | 40 |
setShowAboutMenu(true)} 42 | onMouseLeave={() => setShowAboutMenu(false)} 43 | className="hover:text-[#ff6154] py-4" 44 | 45 | > 46 | About {showAboutMenu && } 47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | export default Menu; 54 | -------------------------------------------------------------------------------- /components/navbar/menus/about-menu.tsx: -------------------------------------------------------------------------------- 1 | const items = [ 2 | { 3 | title: "About Us", 4 | }, 5 | { 6 | title: "Careers", 7 | }, 8 | { 9 | title: "Apps", 10 | }, 11 | { 12 | title: "FAQs", 13 | }, 14 | { 15 | title: "Legal", 16 | }, 17 | ]; 18 | 19 | const AboutMenu = () => { 20 | return ( 21 |
34 |
    35 | {items.map((item, index) => ( 36 |
    37 |
    {item.title}
    38 |
    39 | ))} 40 |
41 |
42 | ); 43 | }; 44 | 45 | export default AboutMenu; -------------------------------------------------------------------------------- /components/navbar/menus/community-menu.tsx: -------------------------------------------------------------------------------- 1 | const items = [ 2 | { 3 | icon: "🎙️", 4 | title: "Discussions", 5 | description: " Check out launches that are coming soon", 6 | }, 7 | { 8 | icon: "✏️", 9 | title: "Stories", 10 | description: "Tech news, interviews and tips from makers", 11 | }, 12 | 13 | { 14 | icon: "💯", 15 | title: "Visit Streaks", 16 | description: "The most active community members", 17 | }, 18 | ]; 19 | 20 | const CommunityMenu = () => { 21 | return ( 22 |
35 |
36 |
37 |
38 | {items.map((item, index) => ( 39 |
40 |
41 | {item.icon} 42 |
43 |
44 |
{item.title}
45 |
{item.description}
46 |
47 |
48 | ))} 49 |
50 |
51 |
52 |
53 | ); 54 | }; 55 | 56 | export default CommunityMenu; -------------------------------------------------------------------------------- /components/navbar/menus/launches-menu.tsx: -------------------------------------------------------------------------------- 1 | const items = [ 2 | { 3 | icon: "🗓️", 4 | title: "Coming Soon", 5 | description: " Check out launches that are coming soon", 6 | }, 7 | { 8 | icon: "❓", 9 | title: "Product Questions", 10 | description: "Answer the most interesting questions", 11 | }, 12 | { 13 | icon: "🔮", 14 | title: "Launch archive", 15 | description: " Most-loved launches by the community", 16 | }, 17 | { 18 | icon: "📰", 19 | title: "Newsletter", 20 | description: "The best of Bird, everyday", 21 | }, 22 | ]; 23 | 24 | const LaunchesMenu = () => { 25 | return ( 26 |
27 |
28 |
29 | {items.map((item, index) => ( 30 |
31 |
32 | {item.icon} 33 |
34 |
35 |
{item.title}
36 |
{item.description}
37 |
38 |
39 | ))} 40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | export default LaunchesMenu; 47 | -------------------------------------------------------------------------------- /components/navbar/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import Logo from "./logo"; 5 | import Menu from "./menu"; 6 | import Search from "./search"; 7 | import SignInButton from "./sign-in-button"; 8 | import SignUpButton from "./sign-up-button"; 9 | import Modal from "../ui/modals/modal"; 10 | import AuthContent from "./auth-content"; 11 | import Avatar from "./avatar"; 12 | import NotificationIcon from "./notification-icon"; 13 | import Submit from "./submit"; 14 | 15 | interface NavbarProps { 16 | authenticatedUser?: any; 17 | notifications?: any; 18 | products?: any; 19 | } 20 | 21 | const Navbar: React.FC = ({ 22 | authenticatedUser, 23 | notifications, 24 | products, 25 | }) => { 26 | const [authModalVisible, setAuthModalVisible] = useState(false); 27 | 28 | const handleButtonClick = () => { 29 | setAuthModalVisible(true); 30 | }; 31 | 32 | return ( 33 |
34 |
35 |
36 | 37 | 38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 | {authenticatedUser ? ( 46 | <> 47 | 48 | 49 | 50 | 51 | ) : ( 52 |
56 | 57 | 58 |
59 | )} 60 |
61 | 62 | 63 | 64 | 65 |
66 |
67 | ); 68 | }; 69 | 70 | export default Navbar; 71 | -------------------------------------------------------------------------------- /components/navbar/notification-icon.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Sheet, 5 | SheetContent, 6 | SheetDescription, 7 | SheetHeader, 8 | SheetTitle, 9 | SheetTrigger, 10 | } from "@/components/ui/sheet"; 11 | import { markAllNotificationsAsRead } from "@/lib/server-actions"; 12 | import Image from "next/image"; 13 | import { useState } from "react"; 14 | import { PiBell } from "react-icons/pi"; 15 | 16 | interface NotificationIconProps { 17 | notifications?: any; 18 | } 19 | 20 | const NotificationIcon: React.FC = ({ 21 | notifications, 22 | }) => { 23 | const [unreadNotifications, setUnreadNotifications] = useState( 24 | notifications?.filter( 25 | (notification: any) => notification.status === "UNREAD" 26 | ).length || 0 27 | ); 28 | 29 | const allNotifications = notifications; 30 | 31 | const timeAgo = (date: string) => { 32 | const now = new Date(); 33 | const time = new Date(date); 34 | const diff = now.getTime() - time.getTime(); 35 | const seconds = diff / 1000; 36 | const minutes = seconds / 60; 37 | const hours = minutes / 60; 38 | const days = hours / 24; 39 | 40 | if (seconds < 60) { 41 | return "Just now"; 42 | } else if (minutes < 60) { 43 | return `${Math.floor(minutes)}m ago`; 44 | } else if (hours < 24) { 45 | return `${Math.floor(hours)}h ago`; 46 | } else { 47 | return `${Math.floor(days)}d ago`; 48 | } 49 | }; 50 | 51 | 52 | const handleMarkAllAsRead = async () => { 53 | try { 54 | //mark all notifications as read with server action 55 | await markAllNotificationsAsRead(); 56 | setUnreadNotifications(0); 57 | } catch (error) { 58 | console.log(error); 59 | } 60 | 61 | }; 62 | 63 | return ( 64 |
65 | 66 | 67 |
68 | 69 | {unreadNotifications > 0 && ( 70 |
79 | {unreadNotifications} 80 |
81 | )} 82 |
83 |
84 | 85 | 86 | Notifications 87 | 88 | View all your notifications here 89 | 90 | 91 | {unreadNotifications === 0 ? ( 92 | <> 93 |
94 |

No new notifications

95 |
96 | 97 | ) : ( 98 |
99 | 105 |
106 | )} 107 | 108 |
109 | {allNotifications?.map((notification: any) => ( 110 |
111 | profile picture 119 | {notification.status === "UNREAD" ? ( 120 |
121 |

122 | {notification.createdAt && 123 | timeAgo(notification.createdAt)} 124 |

125 |

{notification.body}

126 |
127 | ) : ( 128 |
129 |

130 | {notification.createdAt && 131 | timeAgo(notification.createdAt)} 132 |

133 |

134 | {notification.body} 135 |

136 |
137 | )} 138 |
139 | ))} 140 |
141 |
142 |
143 |
144 | ); 145 | }; 146 | 147 | export default NotificationIcon; 148 | -------------------------------------------------------------------------------- /components/navbar/search.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { searchProducts } from "@/lib/server-actions"; 4 | import Image from "next/image"; 5 | 6 | import { useRouter } from "next/navigation"; 7 | import { useEffect, useRef, useState } from "react"; 8 | import { PiMagnifyingGlass } from "react-icons/pi"; 9 | 10 | interface Product { 11 | id: string; 12 | name: string; 13 | slug: string; 14 | headline: string; 15 | description: string; 16 | logo: string; 17 | releaseDate: string; 18 | website: string; 19 | twitter: string; 20 | discord: string; 21 | createdAt: Date; 22 | updatedAt: Date; 23 | userId: string; 24 | status: string; 25 | } 26 | 27 | const Search = () => { 28 | const [query, setQuery] = useState(""); 29 | const [searchResults, setSearchResults] = useState([]); 30 | const [isDropdownVisible, setIsDropdownVisible] = useState(false); 31 | const searchInputRef = useRef(null); 32 | 33 | const router = useRouter(); 34 | 35 | const handleSearch = async (e: React.ChangeEvent) => { 36 | const inputValue = e.target.value; 37 | setQuery(inputValue); 38 | if (inputValue.trim() !== "") { 39 | const products: Product[] = await searchProducts(inputValue); 40 | //filter out the only active products 41 | const activeProducts = products.filter( 42 | (product) => product.status === "ACTIVE" 43 | ); 44 | setSearchResults(activeProducts); 45 | setIsDropdownVisible(true); 46 | } else { 47 | setSearchResults([]); 48 | setIsDropdownVisible(false); 49 | } 50 | }; 51 | 52 | const handleItemClick = (slug: string, productName: string) => { 53 | setQuery(productName); 54 | setIsDropdownVisible(false); 55 | router.push(`/product/${slug}`); 56 | }; 57 | 58 | useEffect(() => { 59 | const handleClickOutside = (event: MouseEvent) => { 60 | if ( 61 | searchInputRef.current && 62 | !searchInputRef.current.contains(event.target as Node) 63 | ) { 64 | setIsDropdownVisible(false); 65 | } 66 | }; 67 | 68 | document.addEventListener("click", handleClickOutside); 69 | 70 | return () => { 71 | document.removeEventListener("click", handleClickOutside); 72 | }; 73 | }, []); 74 | 75 | return ( 76 |
85 | 86 | 87 | 95 | {isDropdownVisible && searchResults.length > 0 && ( 96 |
    97 | {searchResults.map((product) => ( 98 |
  • handleItemClick(product.slug, product.name)} 104 | > 105 | logo 112 | {product.name} 113 |
  • 114 | ))} 115 |
116 | )} 117 |
118 | ); 119 | }; 120 | 121 | export default Search; 122 | -------------------------------------------------------------------------------- /components/navbar/sign-in-button.tsx: -------------------------------------------------------------------------------- 1 | const SignInButton = () => { 2 | return ( 3 |
4 | Sign in 5 |
); 6 | } 7 | 8 | export default SignInButton; -------------------------------------------------------------------------------- /components/navbar/sign-up-button.tsx: -------------------------------------------------------------------------------- 1 | const SignUpButton = () => { 2 | return ( 3 |
12 | Sign up 13 |
); 14 | } 15 | 16 | export default SignUpButton; -------------------------------------------------------------------------------- /components/navbar/submit.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useState } from "react"; 5 | import MembershipModal from "../ui/modals/upgrade-membership-modal"; 6 | import UpgradeMembership from "../upgrade-membership"; 7 | import { isUserPremium } from "@/lib/server-actions"; 8 | 9 | interface SubmitProps { 10 | products: any; 11 | authenticatedUser: any; 12 | } 13 | 14 | const Submit: React.FC = ({ products, authenticatedUser }) => { 15 | const router = useRouter(); 16 | 17 | const [isUpgradeModalVisible, setIsUpgradeModalVisible] = useState(false); 18 | 19 | const handleClick = async () => { 20 | const isPremium = await isUserPremium() 21 | if (!isPremium && products.length === 2) { 22 | setIsUpgradeModalVisible(true); 23 | } else { 24 | router.push("/new-product"); 25 | } 26 | }; 27 | 28 | return ( 29 |
30 | 33 | 37 | 38 | 39 |
40 | ); 41 | }; 42 | 43 | export default Submit; 44 | -------------------------------------------------------------------------------- /components/overview-chart.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React from 'react'; 4 | 5 | import { 6 | BarChart, 7 | Bar, 8 | XAxis, 9 | YAxis, 10 | CartesianGrid, 11 | Tooltip, 12 | Legend, 13 | ResponsiveContainer 14 | } from 'recharts'; 15 | 16 | 17 | 18 | interface OverviewChartProps { 19 | data: any; 20 | } 21 | 22 | const OverviewChart : React.FC = ({data}) => { 23 | 24 | const chartData = Object.keys(data).map((key) => ({ 25 | name: key, 26 | value: data[key] 27 | })) 28 | 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export default OverviewChart; -------------------------------------------------------------------------------- /components/product-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useState } from "react"; 5 | import { 6 | PiArrowBendDoubleUpRight, 7 | PiCaretUpFill, 8 | PiChatCircle, 9 | } from "react-icons/pi"; 10 | import ProductModal from "./ui/modals/product-modal"; 11 | import ProductModalContent from "./product-modal-content"; 12 | import Modal from "./ui/modals/modal"; 13 | import AuthContent from "./navbar/auth-content"; 14 | import Link from "next/link"; 15 | import { upvoteProduct } from "@/lib/server-actions"; 16 | import { motion } from "framer-motion"; 17 | 18 | 19 | interface ProductItemProps { 20 | product: any; 21 | authenticatedUser: any; 22 | } 23 | 24 | const ProductItem: React.FC = ({ 25 | product, 26 | authenticatedUser, 27 | }) => { 28 | const [showLoginModal, setShowLoginModal] = useState(false); 29 | const [showProductModal, setShowProductModal] = useState(false); 30 | const [currentProduct, setCurrentProduct] = useState(null); 31 | 32 | const [hasUpvoted, setHasUpvoted] = useState( 33 | product.upvoters?.includes(authenticatedUser?.user.id) 34 | ); 35 | 36 | const [totalUpvotes, setTotalUpvotes] = useState(product.upvotes || 0); 37 | 38 | const handleProductItemClick = () => { 39 | if (!authenticatedUser) { 40 | setShowLoginModal(true); 41 | } else { 42 | setCurrentProduct(product); 43 | setShowProductModal(true); 44 | } 45 | }; 46 | 47 | const handleArrowClick = ( 48 | e: React.MouseEvent 49 | ) => { 50 | // Prevent the click event from propagating to the product item container 51 | e.stopPropagation(); 52 | // Open the link in a new tab 53 | window.open(`${product.website}`, "_blank"); 54 | }; 55 | 56 | const handleCategoryClick = ( 57 | e: React.MouseEvent 58 | ) => { 59 | e.stopPropagation(); 60 | }; 61 | 62 | 63 | const handleUpvoteClick = async ( 64 | e: React.MouseEvent 65 | ) => { 66 | e.stopPropagation(); 67 | 68 | try { 69 | await upvoteProduct(product.id); 70 | setHasUpvoted(!hasUpvoted); 71 | setTotalUpvotes(hasUpvoted ? totalUpvotes - 1 : totalUpvotes + 1); 72 | } catch (error) { 73 | console.error(error); 74 | } 75 | } 76 | 77 | 78 | 79 | 80 | const releaseDate = product.releaseDate && new Date(product.releaseDate); 81 | 82 | const currentDate = new Date(); 83 | 84 | let displayReleaseDate; 85 | 86 | if (releaseDate > currentDate) { 87 | displayReleaseDate = releaseDate.toDateString(); 88 | } else { 89 | displayReleaseDate = "Available Now"; 90 | } 91 | 92 | const variants = { 93 | initital : { scale: 1 }, 94 | upvoted: { scale: [1, 1.2, 1], transition: { duration: 0.3 } }, 95 | }; 96 | 97 | 98 | 99 | return ( 100 |
111 |
112 |
113 | logo 120 | 121 |
122 |
123 |

{product.name}

124 |

-

125 |

126 | {product.headline} 127 |

128 |
132 | 133 |
134 |
135 |
136 |
137 | {product.commentsLength} 138 | 139 |
140 | 141 | {product.categories.map((category: string) => ( 142 |
143 |
144 |
145 | 150 | {category} 151 | 152 |
153 |
154 | ))} 155 | 156 |
157 |
158 |
159 | {displayReleaseDate} 160 |
161 |
162 |
163 |
164 |
165 | 166 |
167 | 173 | {hasUpvoted ? ( 174 |
180 | 181 | {totalUpvotes} 182 |
183 | ) : ( 184 |
185 | 186 | {totalUpvotes} 187 |
188 | )} 189 |
190 |
191 |
192 | 193 | 194 | 202 | 203 | 204 | 205 | 206 | 207 |
208 | ); 209 | }; 210 | 211 | export default ProductItem; 212 | -------------------------------------------------------------------------------- /components/product-modal-content.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import { 6 | PiCaretUpFill, 7 | PiChatCircle, 8 | PiTrash, 9 | PiUpload, 10 | PiUploadSimple, 11 | } from "react-icons/pi"; 12 | import CarouselComponent from "./carousel-component"; 13 | import { useState } from "react"; 14 | import ShareModal from "./ui/modals/share-product-modal"; 15 | import ShareModalContent from "./share-modal-content"; 16 | import { commentOnProduct, deleteComment, upvoteProduct } from "@/lib/server-actions"; 17 | import { Badge } from "./ui/badge"; 18 | 19 | interface ProductModalContentProps { 20 | currentProduct: any; 21 | authenticatedUser: any; 22 | totalUpvotes: number; 23 | hasUpvoted: boolean; 24 | setTotalUpvotes: any; 25 | setHasUpvoted: any; 26 | } 27 | 28 | const ProductModalContent: React.FC = ({ 29 | currentProduct, 30 | authenticatedUser, 31 | totalUpvotes, 32 | hasUpvoted, 33 | setTotalUpvotes, 34 | setHasUpvoted, 35 | }) => { 36 | const [commentText, setCommentText] = useState(""); 37 | 38 | const [shareModalModalVisible, setShareModalVisible] = useState(false); 39 | 40 | const [comments, setComments] = useState(currentProduct.commentData || []); 41 | 42 | const handleShareClick = () => { 43 | setShareModalVisible(true); 44 | }; 45 | 46 | const handleCommentSubmit = async () => { 47 | try { 48 | // call the comment server action with the product id and the comment text 49 | await commentOnProduct(currentProduct.id, commentText); 50 | 51 | //reset the comment text 52 | setCommentText(""); 53 | setComments([ 54 | ...comments, 55 | { 56 | user: authenticatedUser.user.name, 57 | body: commentText, 58 | profile: authenticatedUser.user.image, 59 | userId: authenticatedUser.user.id, 60 | timestamp: new Date().toISOString(), 61 | } 62 | ]) 63 | } catch (error) { 64 | console.log(error); 65 | } 66 | }; 67 | 68 | const handleCommentChange = (event: any) => { 69 | setCommentText(event.target.value); 70 | }; 71 | 72 | const handleDeleteComment = async (commentId: string) => { 73 | try { 74 | // Call the deleteComment function with the comment ID 75 | await deleteComment(commentId); 76 | // Filter out the deleted comment from the comments state 77 | setComments(comments.filter((comment: any) => comment.id !== commentId)); 78 | } catch (error) { 79 | console.error("Error deleting comment:", error); 80 | // Handle error appropriately, e.g., display an error message to the user 81 | } 82 | }; 83 | 84 | const handleUpvoteClick = async ( 85 | event: React.MouseEvent 86 | ) => { 87 | event.stopPropagation(); 88 | 89 | try { 90 | await upvoteProduct(currentProduct.id); 91 | setTotalUpvotes(hasUpvoted ? totalUpvotes - 1 : totalUpvotes + 1); 92 | setHasUpvoted(!hasUpvoted); 93 | } catch (error) { 94 | console.error("Error upvoting product:", error); 95 | 96 | } 97 | } 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | return ( 106 |
107 |
108 | logo 115 | 116 |
117 |

{currentProduct.name}

118 |
119 |

120 | {currentProduct.headline} 121 |

122 | 123 |
124 | 131 | 132 | 151 |
152 |
153 |

{currentProduct.description}

154 | 155 |
156 |
157 | {currentProduct.categories.map((category: any) => ( 158 | 163 | {category} 164 | 165 | ))} 166 |
167 | 168 |
169 |
173 | 174 |

Discuss

175 |
176 | 177 |
182 | 183 |

Share

184 |
185 |
186 |
187 | 188 | 189 | 190 |

Community Feedback

191 | 192 |
193 |
194 | profile 201 | 202 |