From ddc901f79f19dc0968e3be35231a4d5e026f54bd Mon Sep 17 00:00:00 2001 From: Jeff <3759507+jhuleatt@users.noreply.github.com> Date: Tue, 14 May 2024 14:55:31 -0400 Subject: [PATCH] update to Next 14 and App Hosting (#287) --- nextjs-end/.gitignore | 3 +- nextjs-end/apphosting.yaml | 17 +++ nextjs-end/auth-service-worker.js | 45 ++++++++ nextjs-end/functions/.gitignore | 1 - nextjs-end/functions/index.js | 19 ---- nextjs-end/functions/package.json | 23 ---- nextjs-end/package.json | 24 ++-- nextjs-end/src/app/actions.js | 9 +- nextjs-end/src/app/layout.js | 10 +- nextjs-end/src/app/page.js | 5 +- nextjs-end/src/app/restaurant/[id]/page.jsx | 58 +++++----- nextjs-end/src/app/styles.css | 47 +++++++- nextjs-end/src/components/Header.jsx | 25 ++++- nextjs-end/src/components/Restaurant.jsx | 46 +++----- .../src/components/RestaurantDetails.jsx | 2 + nextjs-end/src/components/ReviewDialog.jsx | 27 ++++- nextjs-end/src/components/Reviews/Review.jsx | 23 ++++ .../src/components/Reviews/ReviewSummary.jsx | 57 ++++++++++ .../src/components/Reviews/ReviewsList.jsx | 37 ++++++ .../components/Reviews/ReviewsListClient.jsx | 45 ++++++++ nextjs-end/src/components/ReviewsList.jsx | 39 ------- nextjs-end/src/lib/firebase/auth.js | 2 +- nextjs-end/src/lib/firebase/clientApp.js | 12 ++ nextjs-end/src/lib/firebase/config.js | 23 +++- nextjs-end/src/lib/firebase/firebase.js | 106 ------------------ nextjs-end/src/lib/firebase/firestore.js | 9 +- nextjs-end/src/lib/firebase/serverApp.js | 13 +++ nextjs-end/src/lib/firebase/storage.js | 2 +- nextjs-end/src/lib/getUser.js | 41 +++---- nextjs-start/.gitignore | 3 +- nextjs-start/apphosting.yaml | 14 +++ nextjs-start/auth-service-worker.js | 34 ++++++ nextjs-start/firebase.json | 69 +++++------- nextjs-start/package.json | 24 ++-- nextjs-start/src/app/actions.js | 6 +- nextjs-start/src/app/layout.js | 10 +- nextjs-start/src/app/page.js | 5 +- nextjs-start/src/app/restaurant/[id]/page.jsx | 57 +++++----- nextjs-start/src/app/styles.css | 47 +++++++- nextjs-start/src/components/Header.jsx | 8 +- nextjs-start/src/components/Restaurant.jsx | 46 +++----- .../src/components/RestaurantDetails.jsx | 2 + nextjs-start/src/components/ReviewDialog.jsx | 27 ++++- .../src/components/Reviews/Review.jsx | 23 ++++ .../src/components/Reviews/ReviewSummary.jsx | 20 ++++ .../src/components/Reviews/ReviewsList.jsx | 37 ++++++ .../components/Reviews/ReviewsListClient.jsx | 45 ++++++++ nextjs-start/src/components/ReviewsList.jsx | 39 ------- nextjs-start/src/lib/firebase/auth.js | 2 +- nextjs-start/src/lib/firebase/clientApp.js | 12 ++ nextjs-start/src/lib/firebase/config.js | 23 +++- nextjs-start/src/lib/firebase/firebase.js | 104 ----------------- nextjs-start/src/lib/firebase/firestore.js | 9 +- nextjs-start/src/lib/firebase/serverApp.js | 27 +++++ nextjs-start/src/lib/firebase/storage.js | 2 +- nextjs-start/src/lib/getUser.js | 41 +++---- 56 files changed, 882 insertions(+), 624 deletions(-) create mode 100644 nextjs-end/apphosting.yaml create mode 100644 nextjs-end/auth-service-worker.js delete mode 100644 nextjs-end/functions/.gitignore delete mode 100644 nextjs-end/functions/index.js delete mode 100644 nextjs-end/functions/package.json create mode 100644 nextjs-end/src/components/Reviews/Review.jsx create mode 100644 nextjs-end/src/components/Reviews/ReviewSummary.jsx create mode 100644 nextjs-end/src/components/Reviews/ReviewsList.jsx create mode 100644 nextjs-end/src/components/Reviews/ReviewsListClient.jsx delete mode 100644 nextjs-end/src/components/ReviewsList.jsx create mode 100644 nextjs-end/src/lib/firebase/clientApp.js delete mode 100644 nextjs-end/src/lib/firebase/firebase.js create mode 100644 nextjs-end/src/lib/firebase/serverApp.js create mode 100644 nextjs-start/apphosting.yaml create mode 100644 nextjs-start/auth-service-worker.js create mode 100644 nextjs-start/src/components/Reviews/Review.jsx create mode 100644 nextjs-start/src/components/Reviews/ReviewSummary.jsx create mode 100644 nextjs-start/src/components/Reviews/ReviewsList.jsx create mode 100644 nextjs-start/src/components/Reviews/ReviewsListClient.jsx delete mode 100644 nextjs-start/src/components/ReviewsList.jsx create mode 100644 nextjs-start/src/lib/firebase/clientApp.js delete mode 100644 nextjs-start/src/lib/firebase/firebase.js create mode 100644 nextjs-start/src/lib/firebase/serverApp.js diff --git a/nextjs-end/.gitignore b/nextjs-end/.gitignore index 347b8c762..8fb25514e 100644 --- a/nextjs-end/.gitignore +++ b/nextjs-end/.gitignore @@ -1,3 +1,4 @@ lib/firebase/config.js .next/ -.firebase/ \ No newline at end of file +.firebase/ +node_modules/ \ No newline at end of file diff --git a/nextjs-end/apphosting.yaml b/nextjs-end/apphosting.yaml new file mode 100644 index 000000000..12017a252 --- /dev/null +++ b/nextjs-end/apphosting.yaml @@ -0,0 +1,17 @@ +env: + # Set this with firebase apphosting:secrets:set + - variable: GEMINI_API_KEY + secret: gemini-api-key + # Get these values from the Firebase console + - variable: NEXT_PUBLIC_FIREBASE_API_KEY + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_PROJECT_ID + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_APP_ID + value: TODO \ No newline at end of file diff --git a/nextjs-end/auth-service-worker.js b/nextjs-end/auth-service-worker.js new file mode 100644 index 000000000..f97408692 --- /dev/null +++ b/nextjs-end/auth-service-worker.js @@ -0,0 +1,45 @@ +import { initializeApp } from "firebase/app"; +import { getAuth, getIdToken } from "firebase/auth"; +import { getInstallations, getToken } from "firebase/installations"; + +// this is set during install +let firebaseConfig; + +self.addEventListener('install', event => { + // extract firebase config from query string + const serializedFirebaseConfig = new URL(location).searchParams.get('firebaseConfig'); + + if (!serializedFirebaseConfig) { + throw new Error('Firebase Config object not found in service worker query string.'); + } + + firebaseConfig = JSON.parse(serializedFirebaseConfig); + console.log("Service worker installed with Firebase config", firebaseConfig); +}); + +self.addEventListener("fetch", (event) => { + const { origin } = new URL(event.request.url); + if (origin !== self.location.origin) return; + event.respondWith(fetchWithFirebaseHeaders(event.request)); +}); + +async function fetchWithFirebaseHeaders(request) { + const app = initializeApp(firebaseConfig); + const auth = getAuth(app); + const installations = getInstallations(app); + const headers = new Headers(request.headers); + const [authIdToken, installationToken] = await Promise.all([ + getAuthIdToken(auth), + getToken(installations), + ]); + headers.append("Firebase-Instance-ID-Token", installationToken); + if (authIdToken) headers.append("Authorization", `Bearer ${authIdToken}`); + const newRequest = new Request(request, { headers }); + return await fetch(newRequest); +} + +async function getAuthIdToken(auth) { + await auth.authStateReady(); + if (!auth.currentUser) return; + return await getIdToken(auth.currentUser); +} diff --git a/nextjs-end/functions/.gitignore b/nextjs-end/functions/.gitignore deleted file mode 100644 index 40b878db5..000000000 --- a/nextjs-end/functions/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ \ No newline at end of file diff --git a/nextjs-end/functions/index.js b/nextjs-end/functions/index.js deleted file mode 100644 index e81477f69..000000000 --- a/nextjs-end/functions/index.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Import function triggers from their respective submodules: - * - * const {onCall} = require("firebase-functions/v2/https"); - * const {onDocumentWritten} = require("firebase-functions/v2/firestore"); - * - * See a full list of supported triggers at https://firebase.google.com/docs/functions - */ - -const {onRequest} = require("firebase-functions/v2/https"); -const logger = require("firebase-functions/logger"); - -// Create and deploy your first functions -// https://firebase.google.com/docs/functions/get-started - -// exports.helloWorld = onRequest((request, response) => { -// logger.info("Hello logs!", {structuredData: true}); -// response.send("Hello from Firebase!"); -// }); diff --git a/nextjs-end/functions/package.json b/nextjs-end/functions/package.json deleted file mode 100644 index 392196b98..000000000 --- a/nextjs-end/functions/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "functions", - "description": "Cloud Functions for Firebase", - "scripts": { - "serve": "firebase emulators:start --only functions", - "shell": "firebase functions:shell", - "start": "npm run shell", - "deploy": "firebase deploy --only functions", - "logs": "firebase functions:log" - }, - "engines": { - "node": "18" - }, - "main": "index.js", - "dependencies": { - "firebase-admin": "^11.8.0", - "firebase-functions": "^4.3.1" - }, - "devDependencies": { - "firebase-functions-test": "^3.1.0" - }, - "private": true -} diff --git a/nextjs-end/package.json b/nextjs-end/package.json index 4de0a37e8..ba1dfd8f1 100644 --- a/nextjs-end/package.json +++ b/nextjs-end/package.json @@ -4,22 +4,19 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "npm run build-service-worker && next build", + "build-service-worker": "npx esbuild auth-service-worker.js --bundle --outfile=public/auth-service-worker.js", "start": "next start", "lint": "next lint" }, "dependencies": { - "@google-cloud/firestore": "^6.7.0", - "firebase": "^10.3.1", - "firebase-admin": "^11.10.1", - "next": "13.4.10", - "protobufjs": "^7.2.5", - "react": "18.2.0", - "react-dom": "18.2.0", - "request": "^2.88.2" - }, - "devDependencies": { - "encoding": "^0.1.13" + "@google/generative-ai": "^0.10.0", + "firebase": "^10.11.1", + "firebase-admin": "^12.1.0", + "next": "^14.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "server-only": "^0.0.1" }, "browser": { "fs": false, @@ -28,5 +25,8 @@ "child_process": false, "net": false, "tls": false + }, + "devDependencies": { + "esbuild": "^0.20.2" } } diff --git a/nextjs-end/src/app/actions.js b/nextjs-end/src/app/actions.js index ec7b21af1..3bba91927 100644 --- a/nextjs-end/src/app/actions.js +++ b/nextjs-end/src/app/actions.js @@ -1,15 +1,14 @@ "use server"; import { addReviewToRestaurant } from "@/src/lib/firebase/firestore.js"; -import { getAuthenticatedAppForUser } from "@/src/lib/firebase/firebase"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp.js"; import { getFirestore } from "firebase/firestore"; -// This is a next.js server action, an alpha feature, so -// use with caution +// This is a Server Action // https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions export async function handleReviewFormSubmission(data) { - const { app } = await getAuthenticatedAppForUser(); - const db = getFirestore(app); + const { firebaseServerApp } = await getAuthenticatedAppForUser(); + const db = getFirestore(firebaseServerApp); await addReviewToRestaurant(db, data.get("restaurantId"), { text: data.get("text"), diff --git a/nextjs-end/src/app/layout.js b/nextjs-end/src/app/layout.js index 0dee17f15..c75b8fc15 100644 --- a/nextjs-end/src/app/layout.js +++ b/nextjs-end/src/app/layout.js @@ -1,6 +1,6 @@ import "@/src/app/styles.css"; import Header from "@/src/components/Header.jsx"; -import { getAuthenticatedAppForUser } from "@/src/lib/firebase/firebase"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; // Force next.js to treat this route as server-side rendered // Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it export const dynamic = "force-dynamic"; @@ -13,15 +13,15 @@ export const metadata = { export default async function RootLayout({ children }) { - const { currentUser } = await getAuthenticatedAppForUser(); + const { currentUser } = await getAuthenticatedAppForUser(); return ( - +
-
{children}
- +
{children}
+ ); diff --git a/nextjs-end/src/app/page.js b/nextjs-end/src/app/page.js index 558891746..c52769ccc 100644 --- a/nextjs-end/src/app/page.js +++ b/nextjs-end/src/app/page.js @@ -1,5 +1,7 @@ import RestaurantListings from "@/src/components/RestaurantListings.jsx"; import { getRestaurants } from "@/src/lib/firebase/firestore.js"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp.js"; +import { getFirestore } from "firebase/firestore"; // Force next.js to treat this route as server-side rendered // Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it @@ -12,7 +14,8 @@ export const dynamic = "force-dynamic"; export default async function Home({ searchParams }) { // Using seachParams which Next.js provides, allows the filtering to happen on the server-side, for example: // ?city=London&category=Indian&sort=Review - const restaurants = await getRestaurants(searchParams); + const {firebaseServerApp} = await getAuthenticatedAppForUser(); + const restaurants = await getRestaurants(getFirestore(firebaseServerApp), searchParams); return (
- -
- ); + return ( +
+ + }> + + + + } + > + + +
+ ); } diff --git a/nextjs-end/src/app/styles.css b/nextjs-end/src/app/styles.css index 5e01d4409..e8fd5c6e2 100644 --- a/nextjs-end/src/app/styles.css +++ b/nextjs-end/src/app/styles.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,500;0,700;1,800&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'); + * { box-sizing: border-box; padding: 0; @@ -5,7 +7,7 @@ } body { - font-family: ui-sans-serif, system-ui, -apple-system; + font-family: "Roboto", ui-sans-serif, system-ui, -apple-system; } ul { @@ -21,18 +23,36 @@ dialog { position: fixed; width: 80vw; height: 50vh; + min-height: 270px; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 999; + border-width: 0px; + border-radius: 0.75rem; + box-shadow: -7px 12px 14px 6px rgb(0 0 0 / 0.2); + & article { background-color: unset; } } + & form { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + } + & footer { - padding: 20px; + padding-right: 20px; + padding-right: 20px; + } + + &::backdrop { + background-color: #F6F7F9; + opacity: 0.8; } } @@ -55,11 +75,13 @@ footer { & .button--cancel { color: rgb(178, 193, 212); background-color: white; + border-radius: 3px; } & .button--confirm { background-color: rgb(255 111 0); color: white; + border-radius: 3px; } & menu { @@ -91,11 +113,18 @@ header { text-decoration: none; color: white; } + .profileImage { + border-radius: 100%; + border-color: white; + border-width: 2px; + border-style: solid; + margin-right: 10px; + } } .logo { display: flex; - align-items: end; + align-items: center; & img { margin-inline-end: 10px; @@ -104,6 +133,7 @@ header { color: white; text-decoration: none; font-size: 1.25rem; + font-weight: 500; } .menu { @@ -152,6 +182,11 @@ header { display: flex; align-items: center; } + + & a { + display: flex; + align-items: center; + } } .main__home { @@ -250,6 +285,12 @@ a { } } +.restaurant__review_summary { + max-width: "50vw"; + height: "75px"; + padding-top: "10px"; +} + .img__section { width: 100%; height: 400px; diff --git a/nextjs-end/src/components/Header.jsx b/nextjs-end/src/components/Header.jsx index 667c39a09..bac460d9e 100644 --- a/nextjs-end/src/components/Header.jsx +++ b/nextjs-end/src/components/Header.jsx @@ -8,11 +8,25 @@ import { } from "@/src/lib/firebase/auth.js"; import { addFakeRestaurantsAndReviews } from "@/src/lib/firebase/firestore.js"; import { useRouter } from "next/navigation"; +import { firebaseConfig } from "@/src/lib/firebase/config"; function useUserSession(initialUser) { // The initialUser comes from the server via a server component const [user, setUser] = useState(initialUser); - const router = useRouter() + const router = useRouter(); + + // Register the service worker that sends auth state back to server + // The service worker is built with npm run build-service-worker + useEffect(() => { + if ("serviceWorker" in navigator) { + const serializedFirebaseConfig = encodeURIComponent(JSON.stringify(firebaseConfig)); + const serviceWorkerUrl = `/auth-service-worker.js?firebaseConfig=${serializedFirebaseConfig}` + + navigator.serviceWorker + .register(serviceWorkerUrl) + .then((registration) => console.log("scope is: ", registration.scope)); + } + }, []); useEffect(() => { const unsubscribe = onAuthStateChanged((authUser) => { @@ -21,7 +35,7 @@ function useUserSession(initialUser) { return () => unsubscribe() // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, []); useEffect(() => { onAuthStateChanged((authUser) => { @@ -62,7 +76,7 @@ export default function Header({initialUser}) { <>

- {user.email} + {user.email} {user.displayName}

@@ -90,9 +104,10 @@ export default function Header({initialUser}) {
) : ( - +
+ A placeholder user image Sign In with Google - +
)}
); diff --git a/nextjs-end/src/components/Restaurant.jsx b/nextjs-end/src/components/Restaurant.jsx index 338a817c9..85848ab68 100644 --- a/nextjs-end/src/components/Restaurant.jsx +++ b/nextjs-end/src/components/Restaurant.jsx @@ -3,35 +3,32 @@ // This components shows one individual restaurant // It receives data from src/app/restaurant/[id]/page.jsx -import { React, useState, useEffect } from "react"; -import { onAuthStateChanged } from "firebase/auth"; +import { React, useState, useEffect, Suspense } from "react"; +import dynamic from 'next/dynamic' import { getRestaurantSnapshotById, - getReviewsSnapshotByRestaurantId, } from "@/src/lib/firebase/firestore.js"; -import { auth } from "@/src/lib/firebase/firebase.js"; -import {getUser} from '@/src/lib/getUser' -import { updateRestaurantImage } from "@/src/lib/firebase/storage.js"; -import ReviewDialog from "@/src/components/ReviewDialog.jsx"; +import {useUser} from '@/src/lib/getUser' import RestaurantDetails from "@/src/components/RestaurantDetails.jsx"; -import ReviewsList from "@/src/components/ReviewsList.jsx"; +import { updateRestaurantImage } from "@/src/lib/firebase/storage.js"; + +const ReviewDialog = dynamic(() => import('@/src/components/ReviewDialog.jsx')); export default function Restaurant({ id, initialRestaurant, - initialReviews, initialUserId, + children }) { - const [restaurant, setRestaurant] = useState(initialRestaurant); + const [restaurantDetails, setRestaurantDetails] = useState(initialRestaurant); const [isOpen, setIsOpen] = useState(false); // The only reason this component needs to know the user ID is to associate a review with the user, and to know whether to show the review dialog - const userId = getUser()?.uid || initialUserId; + const userId = useUser()?.uid || initialUserId; const [review, setReview] = useState({ rating: 0, text: "", }); - const [reviews, setReviews] = useState(initialReviews); const onChange = (value, name) => { setReview({ ...review, [name]: value }); @@ -44,7 +41,7 @@ export default function Restaurant({ } const imageURL = await updateRestaurantImage(id, image); - setRestaurant({ ...restaurant, photo: imageURL }); + setRestaurantDetails({ ...restaurant, photo: imageURL }); } const handleClose = () => { @@ -54,40 +51,31 @@ export default function Restaurant({ useEffect(() => { const unsubscribeFromRestaurant = getRestaurantSnapshotById(id, (data) => { - setRestaurant(data); + setRestaurantDetails(data); }); - const unsubscribeFromReviewsSnapshot = getReviewsSnapshotByRestaurantId( - id, - (data) => { - setReviews(data); - } - ); - return () => { unsubscribeFromRestaurant(); - unsubscribeFromReviewsSnapshot(); }; }, []); return ( -
+ <> - {children} + {userId && Loading...

}> - -
+ />} + ); } diff --git a/nextjs-end/src/components/RestaurantDetails.jsx b/nextjs-end/src/components/RestaurantDetails.jsx index 4171af211..6f65ad8ea 100644 --- a/nextjs-end/src/components/RestaurantDetails.jsx +++ b/nextjs-end/src/components/RestaurantDetails.jsx @@ -9,6 +9,7 @@ const RestaurantDetails = ({ handleRestaurantImage, setIsOpen, isOpen, + children }) => { return (
@@ -54,6 +55,7 @@ const RestaurantDetails = ({ {restaurant.category} | {restaurant.city}

{"$".repeat(restaurant.price)}

+ {children}
diff --git a/nextjs-end/src/components/ReviewDialog.jsx b/nextjs-end/src/components/ReviewDialog.jsx index dd1e685b1..5ab19c607 100644 --- a/nextjs-end/src/components/ReviewDialog.jsx +++ b/nextjs-end/src/components/ReviewDialog.jsx @@ -1,6 +1,8 @@ +'use client'; + // This components handles the review dialog and uses a next.js feature known as Server Actions to handle the form submission -import React from "react"; +import {useEffect, useLayoutEffect, useRef} from "react"; import RatingPicker from "@/src/components/RatingPicker.jsx"; import { handleReviewFormSubmission } from "@/src/app/actions.js"; @@ -12,9 +14,27 @@ const ReviewDialog = ({ userId, id, }) => { + const dialog = useRef(); + + // dialogs only render their backdrop when called with `showModal` + useLayoutEffect(() => { + if (isOpen) { + dialog.current.showModal(); + } else { + dialog.current.close(); + } + + }, [isOpen, dialog.current]); + + const handleClick = (e) => { + // close if clicked outside the modal + if (e.target === dialog.current) { + handleClose(); + } + } + return ( - -
+
{ @@ -62,7 +82,6 @@ const ReviewDialog = ({
-
); }; diff --git a/nextjs-end/src/components/Reviews/Review.jsx b/nextjs-end/src/components/Reviews/Review.jsx new file mode 100644 index 000000000..d52294b2f --- /dev/null +++ b/nextjs-end/src/components/Reviews/Review.jsx @@ -0,0 +1,23 @@ +import renderStars from "@/src/components/Stars.jsx"; + + +export function Review({ rating, text, timestamp }) { + return (
  • + +

    {text}

    + + +
  • ); +} + +export function ReviewSkeleton() { + return (
  • +
    +
    +

    {' '}

    +
  • ); +} diff --git a/nextjs-end/src/components/Reviews/ReviewSummary.jsx b/nextjs-end/src/components/Reviews/ReviewSummary.jsx new file mode 100644 index 000000000..8d3cf9316 --- /dev/null +++ b/nextjs-end/src/components/Reviews/ReviewSummary.jsx @@ -0,0 +1,57 @@ +const { GoogleGenerativeAI } = require("@google/generative-ai"); +import { getReviewsByRestaurantId } from "@/src/lib/firebase/firestore.js"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; +import { getFirestore } from "firebase/firestore"; + +export async function GeminiSummary({ restaurantId }) { + const { firebaseServerApp } = await getAuthenticatedAppForUser(); + const reviews = await getReviewsByRestaurantId( + getFirestore(firebaseServerApp), + restaurantId + ); + + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ model: "gemini-pro"}); + + const reviewSeparator = "@"; + const prompt = ` + Based on the following restaurant reviews, + where each review is separated by a '${reviewSeparator}' character, + create a one-sentence summary of what people think of the restaurant. + + Here are the reviews: ${reviews.map(review => review.text).join(reviewSeparator)} + `; + + try { + const result = await model.generateContent(prompt); + const response = await result.response; + const text = response.text(); + + return ( +
    +

    {text}

    +

    ✨ Summarized with Gemini

    +
    + ); + } catch (e) { + console.error(e); + if (e.message.includes("403 Forbidden")) { + return ( +

    + This service account doesn't have permission to talk to Gemini via + Vertex +

    + ); + } else { + return

    Error contacting Gemini

    ; + } + } +} + +export function GeminiSummarySkeleton() { + return ( +
    +

    ✨ Summarizing reviews with Gemini...

    +
    + ); +} diff --git a/nextjs-end/src/components/Reviews/ReviewsList.jsx b/nextjs-end/src/components/Reviews/ReviewsList.jsx new file mode 100644 index 000000000..939e5b97e --- /dev/null +++ b/nextjs-end/src/components/Reviews/ReviewsList.jsx @@ -0,0 +1,37 @@ +// This component handles the list of reviews for a given restaurant + +import React from "react"; +import { getReviewsByRestaurantId } from "@/src/lib/firebase/firestore.js"; +import ReviewsListClient from "@/src/components/Reviews/ReviewsListClient"; +import { ReviewSkeleton } from "@/src/components/Reviews/Review"; +import { getFirestore } from "firebase/firestore"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; + +export default async function ReviewsList({ restaurantId, userId }) { + const {firebaseServerApp} = await getAuthenticatedAppForUser(); + const reviews = await getReviewsByRestaurantId(getFirestore(firebaseServerApp), restaurantId); + + return ( + + ); +} + +export function ReviewsListSkeleton({ numReviews }) { + return ( +
    + +
    + ); +} diff --git a/nextjs-end/src/components/Reviews/ReviewsListClient.jsx b/nextjs-end/src/components/Reviews/ReviewsListClient.jsx new file mode 100644 index 000000000..ce509540a --- /dev/null +++ b/nextjs-end/src/components/Reviews/ReviewsListClient.jsx @@ -0,0 +1,45 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { getReviewsSnapshotByRestaurantId } from "@/src/lib/firebase/firestore.js"; +import { Review } from "@/src/components/Reviews/Review"; + +export default function ReviewsListClient({ + initialReviews, + restaurantId, + userId, +}) { + const [reviews, setReviews] = useState(initialReviews); + + useEffect(() => { + return getReviewsSnapshotByRestaurantId( + restaurantId, + (data) => { + setReviews(data); + } + ); + }, [restaurantId]); + return ( +
    + +
    + ); +} diff --git a/nextjs-end/src/components/ReviewsList.jsx b/nextjs-end/src/components/ReviewsList.jsx deleted file mode 100644 index 379a0923b..000000000 --- a/nextjs-end/src/components/ReviewsList.jsx +++ /dev/null @@ -1,39 +0,0 @@ -// This component handles the list of reviews for a given restaurant - -import React from "react"; -import renderStars from "@/src/components/Stars.jsx"; - -const ReviewsList = ({ reviews, userId }) => { - return ( -
    - -
    - ); -}; - -export default ReviewsList; diff --git a/nextjs-end/src/lib/firebase/auth.js b/nextjs-end/src/lib/firebase/auth.js index 44a51c17e..362984b30 100644 --- a/nextjs-end/src/lib/firebase/auth.js +++ b/nextjs-end/src/lib/firebase/auth.js @@ -4,7 +4,7 @@ import { onAuthStateChanged as _onAuthStateChanged, } from "firebase/auth"; -import { auth } from "@/src/lib/firebase/firebase"; +import { auth } from "@/src/lib/firebase/clientApp"; export function onAuthStateChanged(cb) { return _onAuthStateChanged(auth, cb); diff --git a/nextjs-end/src/lib/firebase/clientApp.js b/nextjs-end/src/lib/firebase/clientApp.js new file mode 100644 index 000000000..e988ef6ed --- /dev/null +++ b/nextjs-end/src/lib/firebase/clientApp.js @@ -0,0 +1,12 @@ +'use client'; + +import { initializeApp, getApps } from "firebase/app"; +import { firebaseConfig } from "./config"; +import { getAuth } from "firebase/auth"; +import { getFirestore } from "firebase/firestore"; +import { getStorage } from "firebase/storage"; +export const firebaseApp = + getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; +export const auth = getAuth(firebaseApp); +export const db = getFirestore(firebaseApp); +export const storage = getStorage(firebaseApp); diff --git a/nextjs-end/src/lib/firebase/config.js b/nextjs-end/src/lib/firebase/config.js index 2f77b9eaf..00d23aefa 100644 --- a/nextjs-end/src/lib/firebase/config.js +++ b/nextjs-end/src/lib/firebase/config.js @@ -1,7 +1,18 @@ -export default { - projectId: "demo-codelab-nextjs", - appId: "demo-codelab-nextjs", - apiKey: "demo-codelab-nextjs", - storageBucket: "demo-codelab-nextjs.appspot.com", - authDomain: "demo-codelab-nextjs.firebaseapp.com", +const config = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, }; + +// When deployed, there are quotes that need to be stripped +Object.keys(config).forEach((key) => { + const configValue = config[key] + ""; + if (configValue.charAt(0) === '"') { + config[key] = configValue.substring(1, configValue.length - 1); + } +}); + +export const firebaseConfig = config; diff --git a/nextjs-end/src/lib/firebase/firebase.js b/nextjs-end/src/lib/firebase/firebase.js deleted file mode 100644 index 2b7ec867a..000000000 --- a/nextjs-end/src/lib/firebase/firebase.js +++ /dev/null @@ -1,106 +0,0 @@ - -import { initializeApp, getApps } from "firebase/app"; -import { - getAuth, - connectAuthEmulator, - signInWithCustomToken, -} from "firebase/auth"; -import { getFirestore } from "firebase/firestore"; -import { getStorage } from "firebase/storage"; - -export const firebaseConfig = { - apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, - authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, - projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, - storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, -}; - -export const firebaseApp = - getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; -export const auth = getAuth(firebaseApp); -export const db = getFirestore(firebaseApp); -export const storage = getStorage(firebaseApp); - - -export async function getAuthenticatedAppForUser(session = null) { - - - if (typeof window !== "undefined") { - // client - console.log("client: ", firebaseApp); - - return { app: firebaseApp, user: auth.currentUser.toJSON() }; - } - - const { initializeApp: initializeAdminApp, getApps: getAdminApps } = await import("firebase-admin/app"); - - const { getAuth: getAdminAuth } = await import("firebase-admin/auth"); - - const { credential } = await import("firebase-admin"); - - const ADMIN_APP_NAME = "firebase-frameworks"; - const adminApp = - getAdminApps().find((it) => it.name === ADMIN_APP_NAME) || - initializeAdminApp({ - credential: credential.applicationDefault(), - }, ADMIN_APP_NAME); - - const adminAuth = getAdminAuth(adminApp); - const noSessionReturn = { app: null, currentUser: null }; - - - if (!session) { - // if no session cookie was passed, try to get from next/headers for app router - session = await getAppRouterSession(); - - if (!session) return noSessionReturn; - } - - const decodedIdToken = await adminAuth.verifySessionCookie(session); - - const app = initializeAuthenticatedApp(decodedIdToken.uid) - const auth = getAuth(app) - - // handle revoked tokens - const isRevoked = !(await adminAuth - .verifySessionCookie(session, true) - .catch((e) => console.error(e.message))); - if (isRevoked) return noSessionReturn; - - // authenticate with custom token - if (auth.currentUser?.uid !== decodedIdToken.uid) { - // TODO(jamesdaniels) get custom claims - const customToken = await adminAuth - .createCustomToken(decodedIdToken.uid) - .catch((e) => console.error(e.message)); - - if (!customToken) return noSessionReturn; - - await signInWithCustomToken(auth, customToken); - } - console.log("server: ", app); - return { app, currentUser: auth.currentUser }; -} - -async function getAppRouterSession() { - // dynamically import to prevent import errors in pages router - const { cookies } = await import("next/headers"); - - try { - return cookies().get("__session")?.value; - } catch (error) { - // cookies() throws when called from pages router - return undefined; - } -} - -function initializeAuthenticatedApp(uid) { - const random = Math.random().toString(36).split(".")[1]; - const appName = `authenticated-context:${uid}:${random}`; - - const app = initializeApp(firebaseConfig, appName); - - return app; -} diff --git a/nextjs-end/src/lib/firebase/firestore.js b/nextjs-end/src/lib/firebase/firestore.js index 46225591e..69da95b66 100644 --- a/nextjs-end/src/lib/firebase/firestore.js +++ b/nextjs-end/src/lib/firebase/firestore.js @@ -13,9 +13,10 @@ import { runTransaction, where, addDoc, + getFirestore, } from "firebase/firestore"; -import { db } from "@/src/lib/firebase/firebase"; +import { db } from "@/src/lib/firebase/clientApp"; export async function updateRestaurantImageReference( restaurantId, @@ -97,7 +98,7 @@ function applyQueryFilters(q, { category, city, price, sort }) { return q; } -export async function getRestaurants(filters = {}) { +export async function getRestaurants(db = db, filters = {}) { let q = query(collection(db, "restaurants")); q = applyQueryFilters(q, filters); @@ -137,7 +138,7 @@ export function getRestaurantsSnapshot(cb, filters = {}) { return unsubscribe; } -export async function getRestaurantById(restaurantId) { +export async function getRestaurantById(db, restaurantId) { if (!restaurantId) { console.log("Error: Invalid ID received: ", restaurantId); return; @@ -171,7 +172,7 @@ export function getRestaurantSnapshotById(restaurantId, cb) { return unsubscribe; } -export async function getReviewsByRestaurantId(restaurantId) { +export async function getReviewsByRestaurantId(db, restaurantId) { if (!restaurantId) { console.log("Error: Invalid restaurantId received: ", restaurantId); return; diff --git a/nextjs-end/src/lib/firebase/serverApp.js b/nextjs-end/src/lib/firebase/serverApp.js new file mode 100644 index 000000000..5515b12a0 --- /dev/null +++ b/nextjs-end/src/lib/firebase/serverApp.js @@ -0,0 +1,13 @@ +// enforces that this code can only be called on the server +// https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment +import "server-only"; + +import { headers } from "next/headers"; +import { initializeServerApp } from "firebase/app"; + +import { firebaseConfig } from "./config"; +import { getAuth } from "firebase/auth"; + +export async function getAuthenticatedAppForUser() { + throw new Error('not implemented'); +} \ No newline at end of file diff --git a/nextjs-end/src/lib/firebase/storage.js b/nextjs-end/src/lib/firebase/storage.js index f9da55645..a5c7825a2 100644 --- a/nextjs-end/src/lib/firebase/storage.js +++ b/nextjs-end/src/lib/firebase/storage.js @@ -1,6 +1,6 @@ import { ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; -import { storage } from "@/src/lib/firebase/firebase"; +import { storage } from "@/src/lib/firebase/clientApp"; import { updateRestaurantImageReference } from "@/src/lib/firebase/firestore"; diff --git a/nextjs-end/src/lib/getUser.js b/nextjs-end/src/lib/getUser.js index 86db8623f..ecba23187 100644 --- a/nextjs-end/src/lib/getUser.js +++ b/nextjs-end/src/lib/getUser.js @@ -1,33 +1,22 @@ -import { onAuthStateChanged } from 'firebase/auth' -import { useEffect, useState } from 'react' +"use client"; -import { auth } from '@/src/lib/firebase/firebase' -import { useRouter } from 'next/navigation' +import { onAuthStateChanged } from "firebase/auth"; +import { useEffect, useState } from "react"; -export function getUser() { - const [user, setUser] = useState() - const router = useRouter() +import { auth } from "@/src/lib/firebase/clientApp.js"; +import { useRouter } from "next/navigation"; - useEffect(() => { - const unsubscribe = onAuthStateChanged(auth, (authUser) => { - setUser(authUser) - }) +export function useUser() { + const [user, setUser] = useState(); - return () => unsubscribe() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (authUser) => { + setUser(authUser); + }); - useEffect(() => { - onAuthStateChanged(auth, (authUser) => { - if (user === undefined) return + return () => unsubscribe(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // refresh when user changed to ease testing - if (user?.email !== authUser?.email) { - router.refresh() - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user]) - - return user + return user; } diff --git a/nextjs-start/.gitignore b/nextjs-start/.gitignore index 347b8c762..8fb25514e 100644 --- a/nextjs-start/.gitignore +++ b/nextjs-start/.gitignore @@ -1,3 +1,4 @@ lib/firebase/config.js .next/ -.firebase/ \ No newline at end of file +.firebase/ +node_modules/ \ No newline at end of file diff --git a/nextjs-start/apphosting.yaml b/nextjs-start/apphosting.yaml new file mode 100644 index 000000000..f9a39181c --- /dev/null +++ b/nextjs-start/apphosting.yaml @@ -0,0 +1,14 @@ +env: + # Get these values from the Firebase console + - variable: NEXT_PUBLIC_FIREBASE_API_KEY + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_PROJECT_ID + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID + value: TODO + - variable: NEXT_PUBLIC_FIREBASE_APP_ID + value: TODO \ No newline at end of file diff --git a/nextjs-start/auth-service-worker.js b/nextjs-start/auth-service-worker.js new file mode 100644 index 000000000..a27cf9b09 --- /dev/null +++ b/nextjs-start/auth-service-worker.js @@ -0,0 +1,34 @@ +import { initializeApp } from "firebase/app"; +import { getAuth, getIdToken } from "firebase/auth"; +import { getInstallations, getToken } from "firebase/installations"; + +// this is set during install +let firebaseConfig; + +self.addEventListener('install', event => { + // extract firebase config from query string + const serializedFirebaseConfig = new URL(location).searchParams.get('firebaseConfig'); + + if (!serializedFirebaseConfig) { + throw new Error('Firebase Config object not found in service worker query string.'); + } + + firebaseConfig = JSON.parse(serializedFirebaseConfig); + console.log("Service worker installed with Firebase config", firebaseConfig); +}); + +self.addEventListener("fetch", (event) => { + const { origin } = new URL(event.request.url); + if (origin !== self.location.origin) return; + event.respondWith(fetchWithFirebaseHeaders(event.request)); +}); + +// TODO: add Firebase Authentication headers to request +async function fetchWithFirebaseHeaders(request) { + return await fetch(request); +} + +// TODO: get user token +async function getAuthIdToken(auth) { + throw new Error('not implemented'); +} diff --git a/nextjs-start/firebase.json b/nextjs-start/firebase.json index 0f0ea2b9e..2bc5e7504 100644 --- a/nextjs-start/firebase.json +++ b/nextjs-start/firebase.json @@ -1,40 +1,33 @@ { - "emulators": { - "auth": { - "port": 9099 - }, - "functions": { - "port": 5001 - }, - "firestore": { - "port": 8080 - }, - "database": { - "port": 9000 - }, - "storage": { - "port": 9199 - }, - "ui": { - "enabled": true - }, - "singleProjectMode": true, - "hosting": { - "port": 5000 - } - }, - "firestore": { - "rules": "firestore.rules", - "indexes": "firestore.indexes.json" - }, - "hosting": { - "source": ".", - "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], - "frameworksBackend": { - "region": "us-central1" - } - }, - "storage": { - "rules": "storage.rules" - } + "emulators": { + "auth": { + "port": 9099 + }, + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "database": { + "port": 9000 + }, + "storage": { + "port": 9199 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true, + "hosting": { + "port": 5000 + } + }, + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "storage": { + "rules": "storage.rules" + } } diff --git a/nextjs-start/package.json b/nextjs-start/package.json index 4de0a37e8..ba1dfd8f1 100644 --- a/nextjs-start/package.json +++ b/nextjs-start/package.json @@ -4,22 +4,19 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "npm run build-service-worker && next build", + "build-service-worker": "npx esbuild auth-service-worker.js --bundle --outfile=public/auth-service-worker.js", "start": "next start", "lint": "next lint" }, "dependencies": { - "@google-cloud/firestore": "^6.7.0", - "firebase": "^10.3.1", - "firebase-admin": "^11.10.1", - "next": "13.4.10", - "protobufjs": "^7.2.5", - "react": "18.2.0", - "react-dom": "18.2.0", - "request": "^2.88.2" - }, - "devDependencies": { - "encoding": "^0.1.13" + "@google/generative-ai": "^0.10.0", + "firebase": "^10.11.1", + "firebase-admin": "^12.1.0", + "next": "^14.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "server-only": "^0.0.1" }, "browser": { "fs": false, @@ -28,5 +25,8 @@ "child_process": false, "net": false, "tls": false + }, + "devDependencies": { + "esbuild": "^0.20.2" } } diff --git a/nextjs-start/src/app/actions.js b/nextjs-start/src/app/actions.js index b65d82006..ea339e19a 100644 --- a/nextjs-start/src/app/actions.js +++ b/nextjs-start/src/app/actions.js @@ -1,12 +1,10 @@ "use server"; import { addReviewToRestaurant } from "@/src/lib/firebase/firestore.js"; -import { getAuthenticatedAppForUser } from "@/src/lib/firebase/firebase"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp.js"; import { getFirestore } from "firebase/firestore"; -// This is a next.js server action, an alpha feature, so -// use with caution +// This is a Server Action // https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions - // Replace the function below export async function handleReviewFormSubmission(data) {} diff --git a/nextjs-start/src/app/layout.js b/nextjs-start/src/app/layout.js index 0dee17f15..c75b8fc15 100644 --- a/nextjs-start/src/app/layout.js +++ b/nextjs-start/src/app/layout.js @@ -1,6 +1,6 @@ import "@/src/app/styles.css"; import Header from "@/src/components/Header.jsx"; -import { getAuthenticatedAppForUser } from "@/src/lib/firebase/firebase"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; // Force next.js to treat this route as server-side rendered // Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it export const dynamic = "force-dynamic"; @@ -13,15 +13,15 @@ export const metadata = { export default async function RootLayout({ children }) { - const { currentUser } = await getAuthenticatedAppForUser(); + const { currentUser } = await getAuthenticatedAppForUser(); return ( - +
    -
    {children}
    - +
    {children}
    + ); diff --git a/nextjs-start/src/app/page.js b/nextjs-start/src/app/page.js index 558891746..c52769ccc 100644 --- a/nextjs-start/src/app/page.js +++ b/nextjs-start/src/app/page.js @@ -1,5 +1,7 @@ import RestaurantListings from "@/src/components/RestaurantListings.jsx"; import { getRestaurants } from "@/src/lib/firebase/firestore.js"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp.js"; +import { getFirestore } from "firebase/firestore"; // Force next.js to treat this route as server-side rendered // Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it @@ -12,7 +14,8 @@ export const dynamic = "force-dynamic"; export default async function Home({ searchParams }) { // Using seachParams which Next.js provides, allows the filtering to happen on the server-side, for example: // ?city=London&category=Indian&sort=Review - const restaurants = await getRestaurants(searchParams); + const {firebaseServerApp} = await getAuthenticatedAppForUser(); + const restaurants = await getRestaurants(getFirestore(firebaseServerApp), searchParams); return (
    - -
    - ); + return ( +
    + + }> + + + + } + > + + +
    + ); } diff --git a/nextjs-start/src/app/styles.css b/nextjs-start/src/app/styles.css index 5e01d4409..e8fd5c6e2 100644 --- a/nextjs-start/src/app/styles.css +++ b/nextjs-start/src/app/styles.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,500;0,700;1,800&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'); + * { box-sizing: border-box; padding: 0; @@ -5,7 +7,7 @@ } body { - font-family: ui-sans-serif, system-ui, -apple-system; + font-family: "Roboto", ui-sans-serif, system-ui, -apple-system; } ul { @@ -21,18 +23,36 @@ dialog { position: fixed; width: 80vw; height: 50vh; + min-height: 270px; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 999; + border-width: 0px; + border-radius: 0.75rem; + box-shadow: -7px 12px 14px 6px rgb(0 0 0 / 0.2); + & article { background-color: unset; } } + & form { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + } + & footer { - padding: 20px; + padding-right: 20px; + padding-right: 20px; + } + + &::backdrop { + background-color: #F6F7F9; + opacity: 0.8; } } @@ -55,11 +75,13 @@ footer { & .button--cancel { color: rgb(178, 193, 212); background-color: white; + border-radius: 3px; } & .button--confirm { background-color: rgb(255 111 0); color: white; + border-radius: 3px; } & menu { @@ -91,11 +113,18 @@ header { text-decoration: none; color: white; } + .profileImage { + border-radius: 100%; + border-color: white; + border-width: 2px; + border-style: solid; + margin-right: 10px; + } } .logo { display: flex; - align-items: end; + align-items: center; & img { margin-inline-end: 10px; @@ -104,6 +133,7 @@ header { color: white; text-decoration: none; font-size: 1.25rem; + font-weight: 500; } .menu { @@ -152,6 +182,11 @@ header { display: flex; align-items: center; } + + & a { + display: flex; + align-items: center; + } } .main__home { @@ -250,6 +285,12 @@ a { } } +.restaurant__review_summary { + max-width: "50vw"; + height: "75px"; + padding-top: "10px"; +} + .img__section { width: 100%; height: 400px; diff --git a/nextjs-start/src/components/Header.jsx b/nextjs-start/src/components/Header.jsx index 30293adbd..72b143163 100644 --- a/nextjs-start/src/components/Header.jsx +++ b/nextjs-start/src/components/Header.jsx @@ -8,6 +8,7 @@ import { } from "@/src/lib/firebase/auth.js"; import { addFakeRestaurantsAndReviews } from "@/src/lib/firebase/firestore.js"; import { useRouter } from "next/navigation"; +import { firebaseConfig } from "@/src/lib/firebase/config"; function useUserSession(initialUser) { return; @@ -37,7 +38,7 @@ export default function Header({initialUser}) { <>

    - {user.email} + {user.email} {user.displayName}

    @@ -65,9 +66,10 @@ export default function Header({initialUser}) {
    ) : ( - +
    + A placeholder user image Sign In with Google - +
    )}
    ); diff --git a/nextjs-start/src/components/Restaurant.jsx b/nextjs-start/src/components/Restaurant.jsx index 338a817c9..85848ab68 100644 --- a/nextjs-start/src/components/Restaurant.jsx +++ b/nextjs-start/src/components/Restaurant.jsx @@ -3,35 +3,32 @@ // This components shows one individual restaurant // It receives data from src/app/restaurant/[id]/page.jsx -import { React, useState, useEffect } from "react"; -import { onAuthStateChanged } from "firebase/auth"; +import { React, useState, useEffect, Suspense } from "react"; +import dynamic from 'next/dynamic' import { getRestaurantSnapshotById, - getReviewsSnapshotByRestaurantId, } from "@/src/lib/firebase/firestore.js"; -import { auth } from "@/src/lib/firebase/firebase.js"; -import {getUser} from '@/src/lib/getUser' -import { updateRestaurantImage } from "@/src/lib/firebase/storage.js"; -import ReviewDialog from "@/src/components/ReviewDialog.jsx"; +import {useUser} from '@/src/lib/getUser' import RestaurantDetails from "@/src/components/RestaurantDetails.jsx"; -import ReviewsList from "@/src/components/ReviewsList.jsx"; +import { updateRestaurantImage } from "@/src/lib/firebase/storage.js"; + +const ReviewDialog = dynamic(() => import('@/src/components/ReviewDialog.jsx')); export default function Restaurant({ id, initialRestaurant, - initialReviews, initialUserId, + children }) { - const [restaurant, setRestaurant] = useState(initialRestaurant); + const [restaurantDetails, setRestaurantDetails] = useState(initialRestaurant); const [isOpen, setIsOpen] = useState(false); // The only reason this component needs to know the user ID is to associate a review with the user, and to know whether to show the review dialog - const userId = getUser()?.uid || initialUserId; + const userId = useUser()?.uid || initialUserId; const [review, setReview] = useState({ rating: 0, text: "", }); - const [reviews, setReviews] = useState(initialReviews); const onChange = (value, name) => { setReview({ ...review, [name]: value }); @@ -44,7 +41,7 @@ export default function Restaurant({ } const imageURL = await updateRestaurantImage(id, image); - setRestaurant({ ...restaurant, photo: imageURL }); + setRestaurantDetails({ ...restaurant, photo: imageURL }); } const handleClose = () => { @@ -54,40 +51,31 @@ export default function Restaurant({ useEffect(() => { const unsubscribeFromRestaurant = getRestaurantSnapshotById(id, (data) => { - setRestaurant(data); + setRestaurantDetails(data); }); - const unsubscribeFromReviewsSnapshot = getReviewsSnapshotByRestaurantId( - id, - (data) => { - setReviews(data); - } - ); - return () => { unsubscribeFromRestaurant(); - unsubscribeFromReviewsSnapshot(); }; }, []); return ( -
    + <> - {children} + {userId && Loading...

    }> - -
    + />} + ); } diff --git a/nextjs-start/src/components/RestaurantDetails.jsx b/nextjs-start/src/components/RestaurantDetails.jsx index 4171af211..6f65ad8ea 100644 --- a/nextjs-start/src/components/RestaurantDetails.jsx +++ b/nextjs-start/src/components/RestaurantDetails.jsx @@ -9,6 +9,7 @@ const RestaurantDetails = ({ handleRestaurantImage, setIsOpen, isOpen, + children }) => { return (
    @@ -54,6 +55,7 @@ const RestaurantDetails = ({ {restaurant.category} | {restaurant.city}

    {"$".repeat(restaurant.price)}

    + {children}
    diff --git a/nextjs-start/src/components/ReviewDialog.jsx b/nextjs-start/src/components/ReviewDialog.jsx index dd1e685b1..5ab19c607 100644 --- a/nextjs-start/src/components/ReviewDialog.jsx +++ b/nextjs-start/src/components/ReviewDialog.jsx @@ -1,6 +1,8 @@ +'use client'; + // This components handles the review dialog and uses a next.js feature known as Server Actions to handle the form submission -import React from "react"; +import {useEffect, useLayoutEffect, useRef} from "react"; import RatingPicker from "@/src/components/RatingPicker.jsx"; import { handleReviewFormSubmission } from "@/src/app/actions.js"; @@ -12,9 +14,27 @@ const ReviewDialog = ({ userId, id, }) => { + const dialog = useRef(); + + // dialogs only render their backdrop when called with `showModal` + useLayoutEffect(() => { + if (isOpen) { + dialog.current.showModal(); + } else { + dialog.current.close(); + } + + }, [isOpen, dialog.current]); + + const handleClick = (e) => { + // close if clicked outside the modal + if (e.target === dialog.current) { + handleClose(); + } + } + return ( - -
    +
    { @@ -62,7 +82,6 @@ const ReviewDialog = ({
    -
    ); }; diff --git a/nextjs-start/src/components/Reviews/Review.jsx b/nextjs-start/src/components/Reviews/Review.jsx new file mode 100644 index 000000000..d52294b2f --- /dev/null +++ b/nextjs-start/src/components/Reviews/Review.jsx @@ -0,0 +1,23 @@ +import renderStars from "@/src/components/Stars.jsx"; + + +export function Review({ rating, text, timestamp }) { + return (
  • + +

    {text}

    + + +
  • ); +} + +export function ReviewSkeleton() { + return (
  • +
    +
    +

    {' '}

    +
  • ); +} diff --git a/nextjs-start/src/components/Reviews/ReviewSummary.jsx b/nextjs-start/src/components/Reviews/ReviewSummary.jsx new file mode 100644 index 000000000..6188fc3b9 --- /dev/null +++ b/nextjs-start/src/components/Reviews/ReviewSummary.jsx @@ -0,0 +1,20 @@ +const { GoogleGenerativeAI } = require("@google/generative-ai"); +import { getReviewsByRestaurantId } from "@/src/lib/firebase/firestore.js"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; +import { getFirestore } from "firebase/firestore"; + +export async function GeminiSummary({ restaurantId }) { + return ( +
    +

    TODO: summarize reviews

    +
    + ); +} + +export function GeminiSummarySkeleton() { + return ( +
    +

    ✨ Summarizing reviews with Gemini...

    +
    + ); +} diff --git a/nextjs-start/src/components/Reviews/ReviewsList.jsx b/nextjs-start/src/components/Reviews/ReviewsList.jsx new file mode 100644 index 000000000..939e5b97e --- /dev/null +++ b/nextjs-start/src/components/Reviews/ReviewsList.jsx @@ -0,0 +1,37 @@ +// This component handles the list of reviews for a given restaurant + +import React from "react"; +import { getReviewsByRestaurantId } from "@/src/lib/firebase/firestore.js"; +import ReviewsListClient from "@/src/components/Reviews/ReviewsListClient"; +import { ReviewSkeleton } from "@/src/components/Reviews/Review"; +import { getFirestore } from "firebase/firestore"; +import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; + +export default async function ReviewsList({ restaurantId, userId }) { + const {firebaseServerApp} = await getAuthenticatedAppForUser(); + const reviews = await getReviewsByRestaurantId(getFirestore(firebaseServerApp), restaurantId); + + return ( + + ); +} + +export function ReviewsListSkeleton({ numReviews }) { + return ( +
    +
      +
        + {Array(numReviews) + .fill(0) + .map((value, index) => ( + + ))} +
      +
    +
    + ); +} diff --git a/nextjs-start/src/components/Reviews/ReviewsListClient.jsx b/nextjs-start/src/components/Reviews/ReviewsListClient.jsx new file mode 100644 index 000000000..ce509540a --- /dev/null +++ b/nextjs-start/src/components/Reviews/ReviewsListClient.jsx @@ -0,0 +1,45 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { getReviewsSnapshotByRestaurantId } from "@/src/lib/firebase/firestore.js"; +import { Review } from "@/src/components/Reviews/Review"; + +export default function ReviewsListClient({ + initialReviews, + restaurantId, + userId, +}) { + const [reviews, setReviews] = useState(initialReviews); + + useEffect(() => { + return getReviewsSnapshotByRestaurantId( + restaurantId, + (data) => { + setReviews(data); + } + ); + }, [restaurantId]); + return ( +
    +
      + {reviews.length > 0 ? ( +
        + {reviews.map((review) => ( + + ))} +
      + ) : ( +

      + This restaurant has not been reviewed yet,{" "} + {!userId ? "first login and then" : ""} add your own review! +

      + )} +
    +
    + ); +} diff --git a/nextjs-start/src/components/ReviewsList.jsx b/nextjs-start/src/components/ReviewsList.jsx deleted file mode 100644 index 379a0923b..000000000 --- a/nextjs-start/src/components/ReviewsList.jsx +++ /dev/null @@ -1,39 +0,0 @@ -// This component handles the list of reviews for a given restaurant - -import React from "react"; -import renderStars from "@/src/components/Stars.jsx"; - -const ReviewsList = ({ reviews, userId }) => { - return ( -
    -
      - {reviews.length > 0 ? ( -
        - {reviews.map(review => ( -
      • -
          - {renderStars(review.rating)} -
        -

        {review.text}

        - - -
      • - ))} -
      - ) : ( -

      - This restaurant has not been reviewed yet,{" "} - {!userId ? "first login and then" : ""} add your own - review! -

      - )} -
    -
    - ); -}; - -export default ReviewsList; diff --git a/nextjs-start/src/lib/firebase/auth.js b/nextjs-start/src/lib/firebase/auth.js index fe760bc7a..1f5781386 100644 --- a/nextjs-start/src/lib/firebase/auth.js +++ b/nextjs-start/src/lib/firebase/auth.js @@ -4,7 +4,7 @@ import { onAuthStateChanged as _onAuthStateChanged, } from "firebase/auth"; -import { auth } from "@/src/lib/firebase/firebase"; +import { auth } from "@/src/lib/firebase/clientApp"; export function onAuthStateChanged(cb) { return () => {}; diff --git a/nextjs-start/src/lib/firebase/clientApp.js b/nextjs-start/src/lib/firebase/clientApp.js new file mode 100644 index 000000000..e988ef6ed --- /dev/null +++ b/nextjs-start/src/lib/firebase/clientApp.js @@ -0,0 +1,12 @@ +'use client'; + +import { initializeApp, getApps } from "firebase/app"; +import { firebaseConfig } from "./config"; +import { getAuth } from "firebase/auth"; +import { getFirestore } from "firebase/firestore"; +import { getStorage } from "firebase/storage"; +export const firebaseApp = + getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; +export const auth = getAuth(firebaseApp); +export const db = getFirestore(firebaseApp); +export const storage = getStorage(firebaseApp); diff --git a/nextjs-start/src/lib/firebase/config.js b/nextjs-start/src/lib/firebase/config.js index 2f77b9eaf..00d23aefa 100644 --- a/nextjs-start/src/lib/firebase/config.js +++ b/nextjs-start/src/lib/firebase/config.js @@ -1,7 +1,18 @@ -export default { - projectId: "demo-codelab-nextjs", - appId: "demo-codelab-nextjs", - apiKey: "demo-codelab-nextjs", - storageBucket: "demo-codelab-nextjs.appspot.com", - authDomain: "demo-codelab-nextjs.firebaseapp.com", +const config = { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, }; + +// When deployed, there are quotes that need to be stripped +Object.keys(config).forEach((key) => { + const configValue = config[key] + ""; + if (configValue.charAt(0) === '"') { + config[key] = configValue.substring(1, configValue.length - 1); + } +}); + +export const firebaseConfig = config; diff --git a/nextjs-start/src/lib/firebase/firebase.js b/nextjs-start/src/lib/firebase/firebase.js deleted file mode 100644 index 8a88135bf..000000000 --- a/nextjs-start/src/lib/firebase/firebase.js +++ /dev/null @@ -1,104 +0,0 @@ - -import { initializeApp, getApps } from "firebase/app"; -import { - getAuth, - signInWithCustomToken, -} from "firebase/auth"; -import { getFirestore } from "firebase/firestore"; -import { getStorage } from "firebase/storage"; - -export const firebaseConfig = { - apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, - authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, - projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, - storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, -}; - -export const firebaseApp = - getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; -export const auth = getAuth(firebaseApp); -export const db = getFirestore(firebaseApp); -export const storage = getStorage(firebaseApp); - -export async function getAuthenticatedAppForUser(session = null) { - - - if (typeof window !== "undefined") { - // client - console.log("client: ", firebaseApp); - - return { app: firebaseApp, user: auth.currentUser.toJSON() }; - } - - const { initializeApp: initializeAdminApp, getApps: getAdminApps } = await import("firebase-admin/app"); - - const { getAuth: getAdminAuth } = await import("firebase-admin/auth"); - - const { credential } = await import("firebase-admin"); - - const ADMIN_APP_NAME = "firebase-frameworks"; - const adminApp = - getAdminApps().find((it) => it.name === ADMIN_APP_NAME) || - initializeAdminApp({ - credential: credential.applicationDefault(), - }, ADMIN_APP_NAME); - - const adminAuth = getAdminAuth(adminApp); - const noSessionReturn = { app: null, currentUser: null }; - - - if (!session) { - // if no session cookie was passed, try to get from next/headers for app router - session = await getAppRouterSession(); - - if (!session) return noSessionReturn; - } - - const decodedIdToken = await adminAuth.verifySessionCookie(session); - - const app = initializeAuthenticatedApp(decodedIdToken.uid) - const auth = getAuth(app) - - // handle revoked tokens - const isRevoked = !(await adminAuth - .verifySessionCookie(session, true) - .catch((e) => console.error(e.message))); - if (isRevoked) return noSessionReturn; - - // authenticate with custom token - if (auth.currentUser?.uid !== decodedIdToken.uid) { - // TODO(jamesdaniels) get custom claims - const customToken = await adminAuth - .createCustomToken(decodedIdToken.uid) - .catch((e) => console.error(e.message)); - - if (!customToken) return noSessionReturn; - - await signInWithCustomToken(auth, customToken); - } - console.log("server: ", app); - return { app, currentUser: auth.currentUser }; -} - -async function getAppRouterSession() { - // dynamically import to prevent import errors in pages router - const { cookies } = await import("next/headers"); - - try { - return cookies().get("__session")?.value; - } catch (error) { - // cookies() throws when called from pages router - return undefined; - } -} - -function initializeAuthenticatedApp(uid) { - const random = Math.random().toString(36).split(".")[1]; - const appName = `authenticated-context:${uid}:${random}`; - - const app = initializeApp(firebaseConfig, appName); - - return app; -} diff --git a/nextjs-start/src/lib/firebase/firestore.js b/nextjs-start/src/lib/firebase/firestore.js index f03b8fbe8..690baa0fd 100644 --- a/nextjs-start/src/lib/firebase/firestore.js +++ b/nextjs-start/src/lib/firebase/firestore.js @@ -13,9 +13,10 @@ import { runTransaction, where, addDoc, + getFirestore, } from "firebase/firestore"; -import { db } from "@/src/lib/firebase/firebase"; +import { db } from "@/src/lib/firebase/clientApp"; export async function updateRestaurantImageReference( restaurantId, @@ -44,7 +45,7 @@ function applyQueryFilters(q, { category, city, price, sort }) { return; } -export async function getRestaurants(filters = {}) { +export async function getRestaurants(db = db, filters = {}) { return []; } @@ -52,7 +53,7 @@ export function getRestaurantsSnapshot(cb, filters = {}) { return; } -export async function getRestaurantById(restaurantId) { +export async function getRestaurantById(db, restaurantId) { if (!restaurantId) { console.log("Error: Invalid ID received: ", restaurantId); return; @@ -69,7 +70,7 @@ export function getRestaurantSnapshotById(restaurantId, cb) { return; } -export async function getReviewsByRestaurantId(restaurantId) { +export async function getReviewsByRestaurantId(db, restaurantId) { if (!restaurantId) { console.log("Error: Invalid restaurantId received: ", restaurantId); return; diff --git a/nextjs-start/src/lib/firebase/serverApp.js b/nextjs-start/src/lib/firebase/serverApp.js new file mode 100644 index 000000000..24b268d6d --- /dev/null +++ b/nextjs-start/src/lib/firebase/serverApp.js @@ -0,0 +1,27 @@ +// enforces that this code can only be called on the server +// https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment +import "server-only"; + +import { headers } from "next/headers"; +import { initializeServerApp } from "firebase/app"; + +import { firebaseConfig } from "./config"; +import { getAuth } from "firebase/auth"; + +export async function getAuthenticatedAppForUser() { + const idToken = headers().get("Authorization")?.split("Bearer ")[1]; + console.log('firebaseConfig', JSON.stringify(firebaseConfig)); + const firebaseServerApp = initializeServerApp( + firebaseConfig, + idToken + ? { + authIdToken: idToken, + } + : {} + ); + + const auth = getAuth(firebaseServerApp); + await auth.authStateReady(); + + return { firebaseServerApp, currentUser: auth.currentUser }; +} \ No newline at end of file diff --git a/nextjs-start/src/lib/firebase/storage.js b/nextjs-start/src/lib/firebase/storage.js index 96b2299bb..2bb7552a9 100644 --- a/nextjs-start/src/lib/firebase/storage.js +++ b/nextjs-start/src/lib/firebase/storage.js @@ -1,6 +1,6 @@ import { ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; -import { storage } from "@/src/lib/firebase/firebase"; +import { storage } from "@/src/lib/firebase/clientApp"; import { updateRestaurantImageReference } from "@/src/lib/firebase/firestore"; diff --git a/nextjs-start/src/lib/getUser.js b/nextjs-start/src/lib/getUser.js index 86db8623f..ecba23187 100644 --- a/nextjs-start/src/lib/getUser.js +++ b/nextjs-start/src/lib/getUser.js @@ -1,33 +1,22 @@ -import { onAuthStateChanged } from 'firebase/auth' -import { useEffect, useState } from 'react' +"use client"; -import { auth } from '@/src/lib/firebase/firebase' -import { useRouter } from 'next/navigation' +import { onAuthStateChanged } from "firebase/auth"; +import { useEffect, useState } from "react"; -export function getUser() { - const [user, setUser] = useState() - const router = useRouter() +import { auth } from "@/src/lib/firebase/clientApp.js"; +import { useRouter } from "next/navigation"; - useEffect(() => { - const unsubscribe = onAuthStateChanged(auth, (authUser) => { - setUser(authUser) - }) +export function useUser() { + const [user, setUser] = useState(); - return () => unsubscribe() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, (authUser) => { + setUser(authUser); + }); - useEffect(() => { - onAuthStateChanged(auth, (authUser) => { - if (user === undefined) return + return () => unsubscribe(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // refresh when user changed to ease testing - if (user?.email !== authUser?.email) { - router.refresh() - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user]) - - return user + return user; }