diff --git a/README.md b/README.md index 1f6d868..68cfd95 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the ## Setup -1. Create a firebase app and with a firestore database. +1. Create a Firebase app with a Firestore database. -2. Go to firebase project setting > Service account and generate a new private key,Download the json file and add the copy the following environment variables 👇 +2. Go to Firebase project settings > Service Account and generate a new private key. Download the JSON file and copy the following environment variables: 👇 ``` #.env.development.local @@ -26,7 +26,7 @@ FIREBASE_CLIENT_EMAIL= FIREBASE_PRIVATE_KEY= ``` -3. Create a Github auth application and add `https://yourwebsite.com/api/github-auth` as redirect url. Add you github client id and the the secret key to your environment variables: +3. Create a GitHub authentication application and add `https://yourwebsite.com/api/github-auth` as the redirect URL. Add your GitHub client ID and the secret key to your environment variables. ``` NEXT_PUBLIC_GITHUB_OAUTH_CLIENT_ID= @@ -34,4 +34,4 @@ GITHUB_OAUTH_CLIENT_SECRET= ``` -4. Configure ticket generation by updating the `api/og.tsx` file. +4. Configure ticket generation by updating the `app/ticket/og/route.tsx` file. diff --git a/app/github-auth/route.tsx b/app/github-auth/route.tsx new file mode 100644 index 0000000..ac51fb9 --- /dev/null +++ b/app/github-auth/route.tsx @@ -0,0 +1,108 @@ +import { type NextRequest } from "next/server"; +import * as qs from "qs"; +import { saveUser, User } from "@/utils/db"; +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; // defaults to auto + +export async function GET(request: NextRequest) { + // first step + // get user info using github api + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + + if (!code) { + // This happens when user cancelled the authentication. + // In this case, we send an empty message which indicates no data available. + + return Response.json({ + status: 400, + code: "github_issue", + description: "Github redirect code not found, please try again", + }); + } + + const q = qs.stringify({ + client_id: process.env.NEXT_PUBLIC_GITHUB_OAUTH_CLIENT_ID, + client_secret: process.env.GITHUB_OAUTH_CLIENT_SECRET, + code: code, + }); + + const accessTokenRes = await fetch( + `https://github.com/login/oauth/access_token?${q}`, + { + method: "POST", + headers: { + Accept: "application/json", + }, + } + ); + + if (!accessTokenRes.ok) { + console.error( + `Failed to get access token: ${ + accessTokenRes.status + } ${await accessTokenRes.text()}` + ); + + return Response.json({ + status: 400, + code: "github_issue", + description: "Error generating access token, please try again ", + }); + } + + const { access_token: accessToken } = await accessTokenRes.json(); + + const userRes = await fetch("https://api.github.com/user", { + headers: { + Authorization: `bearer ${accessToken as string}`, + }, + }); + + if (!userRes.ok) { + console.error( + `Failed to get GitHub user: ${userRes.status} ${await userRes.text()}` + ); + return Response.json({ + status: 400, + code: "github_issue", + description: "Error retrieving user info , please try again ", + }); + } + + const user: User & { avatar_url: string } = await userRes.json(); + + if (!Boolean(user.login)) { + return Response.json({ + status: 400, + code: "github_issue", + description: "Invalid Github user data", + }); + } + + // save user to database + + try { + await saveUser({ + login: user.login, + email: user.email, + avatar: user.avatar_url, + bio: user.bio, + name: user.name, + }); + // res.status(200).json(user); + // fetch images and dont wait for response + } catch (error) { + console.log(error); + + return Response.json({ + status: 400, + code: "error_database", + description: "Error saving to database ", + error, + }); + } + + redirect(`/ticket/${user.login}/me`); +} diff --git a/app/layout.tsx b/app/layout.tsx index 4eb26c2..56ac8a5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,7 +8,15 @@ import { SalLoader } from "@/components/sal-loader"; // import localFont from "@next/font/local"; -import { Metadata } from "next"; +import { Metadata, Viewport } from "next"; + +export const viewport: Viewport = { + themeColor: "#78543E", + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, +}; // either Static metadata export const metadata: Metadata = { @@ -17,10 +25,11 @@ export const metadata: Metadata = { default: "سوق التيك المغربي | BlaBlaConf 2024", }, description: - "BlaBlaConf 22 | 5+1 Days and 5+1 Tracks covering hottest Technology Trends in Darija", + "5+1 Days and 5+1 Tracks covering hottest Technology Trends in Darija", alternates: { canonical: "/", }, + openGraph: { title: "سوق التيك المغربي | BlaBlaConf 2024", description: diff --git a/app/ticket/[username]/me/page.tsx b/app/ticket/[username]/me/page.tsx index 863b20d..da376af 100644 --- a/app/ticket/[username]/me/page.tsx +++ b/app/ticket/[username]/me/page.tsx @@ -1,11 +1,25 @@ import { TicketHero } from "@/components/ticket-hero"; import { getUserInfo } from "@/utils/ticket-service"; +import type { Metadata } from "next"; -export default async function Page({ - params, -}: { +type Props = { params: { username: string }; -}) { + searchParams: { [key: string]: string | string[] | undefined }; +}; + +export async function generateMetadata({ params }: Props): Promise { + // read route params + const { metadata } = await getUserInfo(params.username); + + // optionally access and extend (rather than replace) parent metadata + if (metadata) { + return metadata; + } + + return {}; +} + +export default async function Page({ params }: Props) { const { user } = await getUserInfo(params.username); return ; } diff --git a/app/ticket/[username]/page.tsx b/app/ticket/[username]/page.tsx index 9f6ccf0..211c593 100644 --- a/app/ticket/[username]/page.tsx +++ b/app/ticket/[username]/page.tsx @@ -1,12 +1,25 @@ import { TicketHero } from "@/components/ticket-hero"; import { getUserInfo } from "@/utils/ticket-service"; +import type { Metadata } from "next"; -export default async function Page({ - params, -}: { +type Props = { params: { username: string }; -}) { - // TODO fetch user da + searchParams: { [key: string]: string | string[] | undefined }; +}; + +export async function generateMetadata({ params }: Props): Promise { + // read route params + const { metadata } = await getUserInfo(params.username); + + // optionally access and extend (rather than replace) parent metadata + if (metadata) { + return metadata; + } + + return {}; +} + +export default async function Page({ params }: Props) { const { user } = await getUserInfo(params.username); return ; } diff --git a/app/ticket/image/route.tsx b/app/ticket/image/route.tsx new file mode 100644 index 0000000..4f36e75 --- /dev/null +++ b/app/ticket/image/route.tsx @@ -0,0 +1,152 @@ +import { ImageResponse } from "next/og"; +// App router includes @vercel/og. +// No need to install it. + +export const runtime = "edge"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + + // ?title= + const hasName = searchParams.has("name"); + const hasLogin = searchParams.has("login"); + const hasAvatar = searchParams.has("avatar"); + const hasTicketNumber = searchParams.has("ticketNumber"); + const name = hasName ? searchParams.get("name") : "Name"; + const login = hasLogin ? searchParams.get("login") : ""; + const avatar = hasAvatar ? searchParams.get("avatar") : ""; + const avatarUrl = avatar === null ? "" : avatar; + const ticketNumber = hasTicketNumber ? searchParams.get("ticketNumber") : ""; + const number = + new Array(7 + 1 - (ticketNumber + "").length).join("0") + ticketNumber; + if (!login) { + return new ImageResponse( + ( + <div + style={{ + display: "flex", + fontSize: 60, + color: "black", + background: "#f6f6f6", + width: "100%", + height: "100%", + paddingTop: 50, + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + textAlign: "center", + }} + > + Ticket dosn't exist please visit blablaconf.com + </div> + ), + { + width: 1200, + height: 630, + } + ); + } + + return new ImageResponse( + ( + <div + style={{ + display: "flex", + fontSize: 60, + color: "black", + background: "#f6f6f6", + width: "100%", + height: "100%", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + }} + > + <img + width="1200" + height="630" + src={`https://res.cloudinary.com/duko2tssr/image/upload/v1706825045/ticket-back_b6qvdk.jpg`} + /> + <div + style={{ + display: "flex", + position: "absolute", + top: 70, + left: 0, + right: 82, + height: 150, + flexDirection: "row-reverse", + }} + > + <img + width="150" + height="150" + style={{ + borderRadius: 100, + }} + src={avatarUrl} + /> + <div + style={{ + display: "flex", + flexDirection: "column", + alignItems: "flex-end", + justifyContent: "center", + marginRight: 30, + }} + > + <span + style={{ + color: "white", + textAlign: "right", + fontWeight: "900", + fontSize: 37, + }} + > + {name} + </span> + <span + style={{ + color: "white", + textAlign: "right", + fontSize: 30, + marginTop: 0, + }} + > + @{login} + </span> + </div> + </div> + + <div + style={{ + display: "flex", + position: "absolute", + top: 250, + right: 150, + height: 150, + width: 300, + // flexDirection: "row-reverse", + transform: "rotate(-40deg)", + }} + > + <span + style={{ + color: "white", + opacity: 0.5, + textAlign: "right", + fontWeight: "900", + fontSize: 54, + }} + > + N {number}{" "} + </span> + </div> + </div> + ), + { + width: 1200, + height: 630, + } + ); +} diff --git a/components/github-button.tsx b/components/github-button.tsx index b390b97..69f847e 100644 --- a/components/github-button.tsx +++ b/components/github-button.tsx @@ -12,7 +12,7 @@ export const GithubButton = () => { <a href={githubUrl} onClick={() => setLoading(true)} - className="shrink-0 m-2 rounded-full bg-[#006233] px-8 py-3 font-medium text-white focus:bg-[#006233] focus:outline-none hover:bg-[#006233]" + className="shrink-0 mt-4 relative px-8 py-3 rounded-full border-2 bg-white/60 flex items-center justify-center font-medium hover:scale-105 transition-all " > {loading ? "Loading Github Profile ...." : "Customize with Github"} </a> diff --git a/components/share-actions.tsx b/components/share-actions.tsx index 881fd8a..a60e62e 100644 --- a/components/share-actions.tsx +++ b/components/share-actions.tsx @@ -32,7 +32,7 @@ export const ShareActions = ({ shareUrl }: { shareUrl: string }) => { return ( <div className="flex md:flex-row flex-col items-center mt-4"> <button - className="shrink-0 rounded-full bg-[#006233] px-8 py-3 font-medium text-white focus:bg-[#006233] focus:outline-none hover:bg-[#006233]" + className="shrink-0 relative px-8 py-2 rounded-full bg-white/60 font-medium hover:scale-105 transition-all " onClick={() => { setCopied(true); copyToClipboard(shareUrl); diff --git a/components/ticket-hero.tsx b/components/ticket-hero.tsx index 4d6a201..6e564bf 100644 --- a/components/ticket-hero.tsx +++ b/components/ticket-hero.tsx @@ -27,12 +27,12 @@ export const TicketHero = ({ url, name, image }: HeroProps) => { return ( <div className="relative bg-opacity-90 py-20 md:pt-20 pt-8 pb-20 min-h-screen"> <div className=" mx-auto max-w-screen-lg md:max-w-screen-xl flex flex-col justify-center items-center md:px-8 px-4 text-center "> - <DateConf /> + {/* <DateConf /> */} <h2 className=" relative md:text-5xl text-4xl font-bold my-4 capitalize max-w-[700px]"> {page === "home" && "Make your own ticket"} {(page === "user" || page === "me") && - `${name}'s BlaBlaConf 2022 Ticket`} + `${name}'s BlaBlaConf 2024 Ticket`} </h2> <p className="text-base font-medium text-gray-600 capitalize max-w-[500px] pt-8"> {page === "home" && @@ -52,7 +52,7 @@ export const TicketHero = ({ url, name, image }: HeroProps) => { {page === "user" && ( <Link href="/" - className="mt-4 shrink-0 m-2 rounded-full bg-[#006233] px-8 py-3 font-medium text-white focus:bg-[#006233] focus:outline-none hover:bg-[#006233]" + className="mt-4 relative px-8 py-3 rounded-full border-2 bg-white/60 flex items-center justify-center font-medium hover:scale-105 transition-all " > Back to Home </Link> @@ -64,15 +64,17 @@ export const TicketHero = ({ url, name, image }: HeroProps) => { <img src={image} alt="BlablaConf Ticket" - className="mx-auto border-2 border-gray-200 rounded-md max-w-2xl w-full" + width={1200} + height={630} + className="mx-auto border-2 border-[#8e664d] rounded-md max-w-2xl w-full aspect-[1200/630]" /> ) : ( <Image src="/images/ticket-placeholder.jpg" alt="BlablaConf Ticket" - width={700} - height={300} - className="mx-auto border-2 border-gray-200 rounded-md" + width={1200} + height={630} + className="mx-auto border-2 border-[#8e664d] rounded-md" /> )} </div> diff --git a/next-seo.config.js b/next-seo.config.js index 3a57e0f..9629429 100644 --- a/next-seo.config.js +++ b/next-seo.config.js @@ -1,13 +1,13 @@ const host = process.env.NEXT_PUBLIC_HOST; export const NEXT_SEO_DEFAULT = { title: - "BlaBlaConf 22 | 5+1 Days and 5+1 Tracks Covering Hottest Technology Trends in Darija", + "BlaBlaConf 24 | 5+1 Days and 5+1 Tracks Covering Hottest Technology Trends in Darija", description: "By the Moroccan developer community, for the Moroccan developer community, BlaBla Conf is your one stop shop for latest and hottest technology trends, in Darija, and completely free! Join us from 19th to 24th December", canonical: `${host}/`, openGraph: { title: - "BlaBlaConf 22 | 5+1 Days and 5+1 Tracks covering hottest Technology Trends in Darija", + "BlaBlaConf 24 | 5+1 Days and 5+1 Tracks covering hottest Technology Trends in Darija", description: "By the Moroccan developer community, for the Moroccan developer community, BlaBla Conf is your one stop shop for latest and hottest technology trends, in Darija, and completely free! Join us from 19th to 24th December", type: "website", diff --git a/styles/globals.css b/styles/globals.css index dece155..d4b1b7a 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -10,7 +10,7 @@ html { } .letter { - background: #fff; + background: #8e664d; box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); position: relative; } @@ -23,14 +23,14 @@ html { z-index: -1; } .letter:before { - background: #fafafa; + background: #8e664d; box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); left: -5px; top: 4px; transform: rotate(-2.5deg); } .letter:after { - background: #f6f6f6; + background: #8e664d; box-shadow: 0 0 3px rgba(0, 0, 0, 0.2); right: -3px; top: 1px; diff --git a/utils/ticket-service.ts b/utils/ticket-service.ts index 5b217fc..42e7e6a 100644 --- a/utils/ticket-service.ts +++ b/utils/ticket-service.ts @@ -1,9 +1,9 @@ -import { NextSeoProps } from "next-seo"; import { getTicketsInfo, User } from "./db"; +import { Metadata } from "next"; -export const generateTicketsSeoConfig = (user: User): NextSeoProps => { +export const generateTicketsMetadata = (user: User): Metadata => { const name = user.name === null ? user.login : user.name; - const seoConfig = { + const metadata: Metadata = { title: name + "'s BlaBlaConf Ticket", description: "By the Moroccan developer community, for the Moroccan developer community, BlaBla Conf is your one stop shop for latest and hottest technology trends, in Darija, and completely free! Join us from 19th to 24th December", @@ -17,22 +17,21 @@ export const generateTicketsSeoConfig = (user: User): NextSeoProps => { images: [ { url: getTicketImg(user), + width: 1200, + height: 630, }, ], - site_name: "blablaconf.com", - imageWidth: 1200, - imageHeight: 630, }, }; - return seoConfig; + return metadata; }; const getTicketImg = (user: User) => { const name = user.name === null ? user.login : user.name; const ticketImg = `${ process.env.NEXT_PUBLIC_HOST - }/api/og?name=${encodeURIComponent(name)}&login=${encodeURIComponent( + }/ticket/image?name=${encodeURIComponent(name)}&login=${encodeURIComponent( user.login )}&avatar=${encodeURIComponent( user.avatar @@ -41,7 +40,7 @@ const getTicketImg = (user: User) => { }; export const getUserInfo = async (username: string) => { - let seoConfig = null; + let metadata: Metadata | null = null; let user = null; if (username && username !== "") { @@ -49,7 +48,7 @@ export const getUserInfo = async (username: string) => { if (userDoc.exists) { // TODO: only pass required data const u = userDoc.data() as User; - seoConfig = generateTicketsSeoConfig(u); + metadata = generateTicketsMetadata(u); user = { name: u.name === null ? u.login : u.name, image: getTicketImg(u), @@ -57,5 +56,5 @@ export const getUserInfo = async (username: string) => { }; } } - return { seoConfig, user }; + return { metadata, user }; };