diff --git a/nextjs-start/.gitignore b/nextjs-start/.gitignore new file mode 100644 index 000000000..aced64b22 --- /dev/null +++ b/nextjs-start/.gitignore @@ -0,0 +1 @@ +.next/ \ No newline at end of file diff --git a/nextjs-start/components/Filters.jsx b/nextjs-start/components/Filters.jsx new file mode 100644 index 000000000..e07b09e42 --- /dev/null +++ b/nextjs-start/components/Filters.jsx @@ -0,0 +1,165 @@ +// The filters shown on the restaurant listings page + +import Tag from "@/components/Tag.jsx"; + +function FilterSelect({ label, options, value, onChange, name, icon }) { + return ( +
+ {label} + +
+ ); +} + +export default function Filters({ filters, setFilters }) { + const handleSelectionChange = (event, name) => { + setFilters(prevFilters => ({ + ...prevFilters, + [name]: event.target.value, + })); + }; + + const updateField = (type, value) => { + setFilters({ ...filters, [type]: value }); + }; + + return ( +
+
+ + filter +
+

Restaurants

+

Sorted by {filters.sort || "Rating"}

+
+
+ +
{ + event.preventDefault(); + event.target.parentNode.removeAttribute("open"); + }} + > + + handleSelectionChange(event, "category") + } + name="category" + icon="/food.svg" + /> + + handleSelectionChange(event, "city")} + name="city" + icon="/location.svg" + /> + + + handleSelectionChange(event, "price") + } + name="price" + icon="/price.svg" + /> + + handleSelectionChange(event, "sort")} + name="sort" + icon="/sortBy.svg" + /> + +
+ + + + +
+ +
+ +
+ {Object.entries(filters).map(([type, value]) => { + // The main filter bar already specifies what + // sorting is being used. So skip showing the + // sorting as a 'tag' + if (type == "sort" || value == "") { + return null; + } + return ( + + ); + })} +
+
+ ); +} diff --git a/nextjs-start/components/Header.jsx b/nextjs-start/components/Header.jsx new file mode 100644 index 000000000..9e6d8bc93 --- /dev/null +++ b/nextjs-start/components/Header.jsx @@ -0,0 +1,73 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import Link from "next/link"; +import { + signInWithGoogle, + signOut, + onAuthStateChanged, +} from "@/lib/firebase/auth.js"; +import { addFakeRestaurantsAndReviews } from "@/lib/firebase/firestore.js"; + +async function handleUserSession() {} + +function useUserSession() {} + +export default function Header({ initialUser }) { + const user = useUserSession(initialUser); + + const handleSignOut = event => { + event.preventDefault(); + signOut(); + }; + + const handleSignIn = event => { + event.preventDefault(); + signInWithGoogle(); + }; + + return ( +
+ + FriendlyEats + Friendly Eats + + {user ? ( + <> +
+

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

+ +
+ ... + +
+
+ + ) : ( + + Sign In with Google + + )} +
+ ); +} diff --git a/nextjs-start/components/RatingPicker.jsx b/nextjs-start/components/RatingPicker.jsx new file mode 100644 index 000000000..1b2ce1146 --- /dev/null +++ b/nextjs-start/components/RatingPicker.jsx @@ -0,0 +1,66 @@ +import React from "react"; + +// A HTML and CSS only rating picker thanks to: https://codepen.io/chris22smith/pen/MJzLJN + +const RatingPicker = () => { + return ( +

+ + + + + + + + + + + + + + +

+ ); +}; + +export default RatingPicker; diff --git a/nextjs-start/components/Restaurant.jsx b/nextjs-start/components/Restaurant.jsx new file mode 100644 index 000000000..d9eff4272 --- /dev/null +++ b/nextjs-start/components/Restaurant.jsx @@ -0,0 +1,107 @@ +"use client"; + +// This components shows one individual restaurant +// It receives data from src/app/restaurant/[id]/page.jsx + +import { React, useState, useEffect } from "react"; +import { + getRestaurantSnapshotById, + getReviewsSnapshotByRestaurantId, +} from "@/lib/firebase/firestore.js"; +import { updateRestaurantImage } from "@/lib/firebase/storage.js"; +import { onAuthStateChanged } from "@/lib/firebase/auth.js"; +import ReviewDialog from "@/components/ReviewDialog.jsx"; +import RestaurantDetails from "@/components/RestaurantDetails.jsx"; +import ReviewsList from "@/components/ReviewsList.jsx"; + +export default function Restaurant({ + id, + initialRestaurant, + initialReviews, + initialUser, +}) { + const [restaurant, setRestaurant] = 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, setUserId] = useState(initialUser?.id ?? ""); + const [review, setReview] = useState({ + rating: 0, + text: "", + }); + const [reviews, setReviews] = useState(initialReviews); + + const onChange = (value, name) => { + setReview({ ...review, [name]: value }); + }; + + useEffect(() => { + const unsubscribe = onAuthStateChanged(user => { + if (user) { + setUserId(user.uid); + } else { + setUserId(""); + } + }); + return () => { + unsubscribe(); + }; + }, []); + + async function handleRestaurantImage(target) { + const image = target.files ? target.files[0] : null; + if (!image) { + return; + } + + const imageURL = await updateRestaurantImage(id, image); + setRestaurant({ ...restaurant, photo: imageURL }); + } + + const handleClose = () => { + setIsOpen(false); + setReview({ rating: 0, text: "" }); + }; + + useEffect(() => { + const unsubscribeFromRestaurant = getRestaurantSnapshotById( + id, + data => { + setRestaurant(data); + } + ); + + const unsubscribeFromReviewsSnapshot = getReviewsSnapshotByRestaurantId( + id, + data => { + setReviews(data); + } + ); + + return () => { + unsubscribeFromRestaurant(); + unsubscribeFromReviewsSnapshot(); + }; + }, []); + + return ( +
+ + + +
+ ); +} diff --git a/nextjs-start/components/RestaurantDetails.jsx b/nextjs-start/components/RestaurantDetails.jsx new file mode 100644 index 000000000..42d63c311 --- /dev/null +++ b/nextjs-start/components/RestaurantDetails.jsx @@ -0,0 +1,63 @@ +// This component shows restaurant metadata, and offers some actions to the user like uploading a new restaurant image, and adding a review. + +import React from "react"; +import renderStars from "@/components/Stars.jsx"; + +const RestaurantDetails = ({ + restaurant, + userId, + handleRestaurantImage, + setIsOpen, + isOpen, +}) => { + return ( +
+ {restaurant.name} + +
+ {userId && ( + { + setIsOpen(!isOpen); + }} + src="/review.svg" + /> + )} + +
+ +
+
+

{restaurant.name}

+ +
+
    {renderStars(restaurant.avgRating)}
+ + ({restaurant.numRatings}) +
+ +

+ {restaurant.category} | {restaurant.city} +

+

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

+
+
+
+ ); +}; + +export default RestaurantDetails; diff --git a/nextjs-start/components/RestaurantListings.jsx b/nextjs-start/components/RestaurantListings.jsx new file mode 100644 index 000000000..f2142ef9c --- /dev/null +++ b/nextjs-start/components/RestaurantListings.jsx @@ -0,0 +1,115 @@ +"use client"; + +// This components handles the restaurant listings page +// It receives data from src/app/page.jsx, such as the initial restaurants and search params from the URL + +import Link from "next/link"; +import { React, useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import renderStars from "@/components/Stars.jsx"; +import { getRestaurantsSnapshot } from "@/lib/firebase/firestore.js"; +import Filters from "@/components/Filters.jsx"; + +const RestaurantItem = ({ restaurant }) => ( +
  • + + + +
  • +); + +const ActiveResturant = ({ restaurant }) => ( +
    + + +
    +); + +const ImageCover = ({ photo, name }) => ( +
    + {name} +
    +); + +const ResturantDetails = ({ restaurant }) => ( +
    +

    {restaurant.name}

    + + +
    +); + +const RestaurantRating = ({ restaurant }) => ( +
    + + ({restaurant.numRatings}) +
    +); + +const RestaurantMetadata = ({ restaurant }) => ( +
    +

    + {restaurant.category} | {restaurant.city} +

    +

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

    +
    +); + +export default function RestaurantListings({ + initialRestaurants, + searchParams, +}) { + const router = useRouter(); + + // The initial filters are the search params from the URL, useful for when the user refreshes the page + const initialFilters = { + city: searchParams.city || "", + category: searchParams.category || "", + price: searchParams.price || "", + sort: searchParams.sort || "", + }; + + const [restaurants, setRestaurants] = useState(initialRestaurants); + const [filters, setFilters] = useState(initialFilters); + + useEffect(() => { + routerWithFilters(router, filters); + }, [filters]); + + useEffect(() => { + const unsubscribe = getRestaurantsSnapshot(data => { + setRestaurants(data); + }, filters); + + return () => { + unsubscribe(); + }; + }, [filters]); + + return ( +
    + + +
    + ); +} + +function routerWithFilters(router, filters) { + const queryParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== "") { + queryParams.append(key, value); + } + } + + const queryString = queryParams.toString(); + router.push(`?${queryString}`); +} diff --git a/nextjs-start/components/ReviewDialog.jsx b/nextjs-start/components/ReviewDialog.jsx new file mode 100644 index 000000000..357b2b7fe --- /dev/null +++ b/nextjs-start/components/ReviewDialog.jsx @@ -0,0 +1,70 @@ +// 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 RatingPicker from "@/components/RatingPicker.jsx"; +import { handleReviewFormSubmission } from "@/src/app/actions.js"; + +const ReviewDialog = ({ + isOpen, + handleClose, + review, + onChange, + userId, + id, +}) => { + return ( + +
    +
    { + handleClose(); + }} + > +
    +

    Add your review

    +
    +
    + + +

    + onChange(e.target.value, "text")} + /> +

    + + + +
    + +
    +
    +
    + ); +}; + +export default ReviewDialog; diff --git a/nextjs-start/components/ReviewsList.jsx b/nextjs-start/components/ReviewsList.jsx new file mode 100644 index 000000000..543c9f3f0 --- /dev/null +++ b/nextjs-start/components/ReviewsList.jsx @@ -0,0 +1,39 @@ +// This component handles the list of reviews for a given restaurant + +import React from "react"; +import renderStars from "@/components/Stars.jsx"; + +const ReviewsList = ({ reviews, userId }) => { + return ( +
    + +
    + ); +}; + +export default ReviewsList; diff --git a/nextjs-start/components/Stars.jsx b/nextjs-start/components/Stars.jsx new file mode 100644 index 000000000..2ddc2e0a4 --- /dev/null +++ b/nextjs-start/components/Stars.jsx @@ -0,0 +1,45 @@ +// This component displays star ratings + +export default function renderStars(avgRating) { + const arr = []; + for (let i = 0; i < 5; i++) { + if (i < Math.floor(avgRating)) { + arr.push( +
  • + + + +
  • + ); + } else { + arr.push( +
  • + + + +
  • + ); + } + } + return arr; +} diff --git a/nextjs-start/components/Tag.jsx b/nextjs-start/components/Tag.jsx new file mode 100644 index 000000000..986bc24ca --- /dev/null +++ b/nextjs-start/components/Tag.jsx @@ -0,0 +1,18 @@ +// A tag is shown under the filter bar when a filter is selected. +// Tags show what filters have been selected +// On click, the tag is removed and the filter is reset + +export default function Tag({ type, value, updateField }) { + return ( + + {value} + + + ); +} diff --git a/nextjs-start/jsconfig.json b/nextjs-start/jsconfig.json new file mode 100644 index 000000000..5c64b0675 --- /dev/null +++ b/nextjs-start/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/nextjs-start/lib/fakeRestaurants.js b/nextjs-start/lib/fakeRestaurants.js new file mode 100644 index 000000000..158293609 --- /dev/null +++ b/nextjs-start/lib/fakeRestaurants.js @@ -0,0 +1,88 @@ +import { + randomNumberBetween, + getRandomDateAfter, + getRandomDateBefore, +} from "@/lib/utils.js"; +import { randomData } from "@/lib/randomData.js"; + +import { Timestamp } from "firebase/firestore"; + +export async function generateFakeRestaurantsAndReviews() { + const restaurantsToAdd = 5; + const data = []; + + for (let i = 0; i < restaurantsToAdd; i++) { + const restaurantTimestamp = Timestamp.fromDate(getRandomDateBefore()); + + const ratingsData = []; + + // Generate a random number of ratings/reviews for this restaurant + for (let j = 0; j < randomNumberBetween(0, 5); j++) { + const ratingTimestamp = Timestamp.fromDate( + getRandomDateAfter(restaurantTimestamp.toDate()) + ); + + const ratingData = { + rating: randomData.restaurantReviews[ + randomNumberBetween( + 0, + randomData.restaurantReviews.length - 1 + ) + ].rating, + text: randomData.restaurantReviews[ + randomNumberBetween( + 0, + randomData.restaurantReviews.length - 1 + ) + ].text, + userId: `User #${randomNumberBetween()}`, + timestamp: ratingTimestamp, + }; + + ratingsData.push(ratingData); + } + + const avgRating = ratingsData.length + ? ratingsData.reduce( + (accumulator, currentValue) => + accumulator + currentValue.rating, + 0 + ) / ratingsData.length + : 0; + + const restaurantData = { + category: + randomData.restaurantCategories[ + randomNumberBetween( + 0, + randomData.restaurantCategories.length - 1 + ) + ], + name: randomData.restaurantNames[ + randomNumberBetween(0, randomData.restaurantNames.length - 1) + ], + avgRating, + city: randomData.restaurantCities[ + randomNumberBetween(0, randomData.restaurantCities.length - 1) + ], + numRatings: ratingsData.length, + sumRating: ratingsData.reduce( + (accumulator, currentValue) => + accumulator + currentValue.rating, + 0 + ), + price: randomNumberBetween(1, 4), + photo: `https://storage.googleapis.com/firestorequickstarts.appspot.com/food_${randomNumberBetween( + 1, + 22 + )}.png`, + timestamp: restaurantTimestamp, + }; + + data.push({ + restaurantData, + ratingsData, + }); + } + return data; +} diff --git a/nextjs-start/lib/firebase/auth.js b/nextjs-start/lib/firebase/auth.js new file mode 100644 index 000000000..db26d411b --- /dev/null +++ b/nextjs-start/lib/firebase/auth.js @@ -0,0 +1,14 @@ +import { + GoogleAuthProvider, + signInWithPopup, + onAuthStateChanged as _onAuthStateChanged, +} from "firebase/auth"; + +import { auth } from "@/lib/firebase/firebase"; + +// Replace the following three empty functions definitions +export function onAuthStateChanged() {} + +export async function signInWithGoogle() {} + +export async function signOut() {} diff --git a/nextjs-start/lib/firebase/config-copy.js b/nextjs-start/lib/firebase/config-copy.js new file mode 100644 index 000000000..2f77b9eaf --- /dev/null +++ b/nextjs-start/lib/firebase/config-copy.js @@ -0,0 +1,7 @@ +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", +}; diff --git a/nextjs-start/lib/firebase/config.js b/nextjs-start/lib/firebase/config.js new file mode 100644 index 000000000..2f77b9eaf --- /dev/null +++ b/nextjs-start/lib/firebase/config.js @@ -0,0 +1,7 @@ +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", +}; diff --git a/nextjs-start/lib/firebase/firebase.js b/nextjs-start/lib/firebase/firebase.js new file mode 100644 index 000000000..e0b8f515d --- /dev/null +++ b/nextjs-start/lib/firebase/firebase.js @@ -0,0 +1,18 @@ +import firebaseConfig from "@/lib/firebase/config.js"; +import { initializeApp } from "firebase/app"; + +import { getFirestore, connectFirestoreEmulator } from "firebase/firestore"; + +import { getAuth, connectAuthEmulator } from "firebase/auth"; + +import { getStorage, connectStorageEmulator } from "firebase/storage"; + +import { getApps } from "firebase/app"; + +let app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0]; + +export const db = getFirestore(app); +export const storage = getStorage(app); +export const auth = getAuth(app); + +// Connect firebase services to emulators diff --git a/nextjs-start/lib/firebase/firestore.js b/nextjs-start/lib/firebase/firestore.js new file mode 100644 index 000000000..ddaf68225 --- /dev/null +++ b/nextjs-start/lib/firebase/firestore.js @@ -0,0 +1,145 @@ +import { generateFakeRestaurantsAndReviews } from "@/lib/fakeRestaurants.js"; + +import { + collection, + onSnapshot, + query, + getDocs, + doc, + getDoc, + updateDoc, + orderBy, + Timestamp, + runTransaction, + where, + addDoc, +} from "firebase/firestore"; + +import { db } from "@/lib/firebase/firebase"; + +export async function updateRestaurantImageReference( + restaurantId, + publicImageUrl +) { + const restaurantRef = doc(collection(db, "restaurants"), restaurantId); + if (restaurantRef) { + await updateDoc(restaurantRef, { photo: publicImageUrl }); + } +} + +const updateWithRating = async () => {}; + +// Replace this function +export async function addReviewToRestaurant() {} + +// Replace this function +function applyQueryFilters() {} + +// Replace this function +export async function getRestaurants() {} + +// Replace this function +export function getRestaurantsSnapshot() {} + +export async function getRestaurantById(restaurantId) { + if (!restaurantId) { + console.log("Error: Invalid ID received: ", restaurantId); + return; + } + const docRef = doc(db, "restaurants", restaurantId); + const docSnap = await getDoc(docRef); + return { + ...docSnap.data(), + timestamp: docSnap.data().timestamp.toDate(), + }; +} + +export function getRestaurantSnapshotById(restaurantId, cb) { + if (!restaurantId) { + console.log("Error: Invalid ID received: ", restaurantId); + return; + } + + if (typeof cb !== "function") { + console.log("Error: The callback parameter is not a function"); + return; + } + + const docRef = doc(db, "restaurants", restaurantId); + const unsubscribe = onSnapshot(docRef, docSnap => { + cb({ + ...docSnap.data(), + timestamp: docSnap.data().timestamp.toDate(), + }); + }); + return unsubscribe; +} + +export async function getReviewsByRestaurantId(restaurantId) { + if (!restaurantId) { + console.log("Error: Invalid restaurantId received: ", restaurantId); + return; + } + + const q = query( + collection(db, "restaurants", restaurantId, "ratings"), + orderBy("timestamp", "desc") + ); + + const results = await getDocs(q); + return results.docs.map(doc => { + return { + id: doc.id, + ...doc.data(), + // Only plain objects can be passed to Client Components from Server Components + timestamp: doc.data().timestamp.toDate(), + }; + }); +} + +export function getReviewsSnapshotByRestaurantId(restaurantId, cb) { + if (!restaurantId) { + console.log("Error: Invalid restaurantId received: ", restaurantId); + return; + } + + const q = query( + collection(db, "restaurants", restaurantId, "ratings"), + orderBy("timestamp", "desc") + ); + const unsubscribe = onSnapshot(q, querySnapshot => { + const results = querySnapshot.docs.map(doc => { + return { + id: doc.id, + ...doc.data(), + // Only plain objects can be passed to Client Components from Server Components + timestamp: doc.data().timestamp.toDate(), + }; + }); + cb(results); + }); + return unsubscribe; +} + +export async function addFakeRestaurantsAndReviews() { + const data = await generateFakeRestaurantsAndReviews(); + + for (const { restaurantData, ratingsData } of data) { + try { + const docRef = await addDoc( + collection(db, "restaurants"), + restaurantData + ); + + for (const ratingData of ratingsData) { + await addDoc( + collection(db, "restaurants", docRef.id, "ratings"), + ratingData + ); + } + } catch (e) { + console.log("There was an error adding the document"); + console.error("Error adding document: ", e); + } + } +} diff --git a/nextjs-start/lib/firebase/storage.js b/nextjs-start/lib/firebase/storage.js new file mode 100644 index 000000000..01b9e8934 --- /dev/null +++ b/nextjs-start/lib/firebase/storage.js @@ -0,0 +1,11 @@ +import { ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; + +import { storage } from "@/lib/firebase/firebase"; + +import { updateRestaurantImageReference } from "@/lib/firebase/firestore"; + +// Replace this function +export async function updateRestaurantImage() {} + +// Replace this function +async function uploadImage() {} diff --git a/nextjs-start/lib/getUser.js b/nextjs-start/lib/getUser.js new file mode 100644 index 000000000..1e0c87338 --- /dev/null +++ b/nextjs-start/lib/getUser.js @@ -0,0 +1,4 @@ +import { cookies } from "next/headers"; + +// Replace this function +export default function getUser() {} diff --git a/nextjs-start/lib/randomData.js b/nextjs-start/lib/randomData.js new file mode 100644 index 000000000..41045dc8a --- /dev/null +++ b/nextjs-start/lib/randomData.js @@ -0,0 +1,148 @@ +// Random restaurant data that is added to Firestore +// After you've signed into Friendly Eats, click the dropdown +// menu in the top right corner and select "Add random data" + +export const randomData = { + restaurantNames: [ + "Savory Bites", + "Gourmet Delight", + "Wholesome Kitchen", + "Cheesy Cravings", + "Spice Fusion", + "Burger Bonanza", + "Pasta Paradise", + "Taco Tango", + "Sushi Sensation", + "Pizza Pizzazz", + "Cafe Mocha", + "Mouthwatering BBQ", + "Thai Temptations", + "Sizzling Steaks", + "Veggie Heaven", + "Seafood Symphony", + "Wok & Roll", + "French Flavors", + "Tandoori Nights", + "Mediterranean Magic", + "Enchilada Express", + "Ramen Ramble", + "Deli Delights", + "Crepes & Co.", + "Hot Pot Haven", + "Tasty Tandoor", + "Bistro Bliss", + "Curry Corner", + "Pancake Paradise", + "Pita Panache", + "Biryani Bliss", + "Garden Grub", + "Dim Sum Delish", + "Cajun Craze", + "Fondue Fantasy", + "Bagel Bar", + "Tapas Talk", + "Pho Fusion", + "Brunch Bunch", + "Steaming Samosas", + "Falafel Frenzy", + ], + restaurantCities: [ + "New York", + "Los Angeles", + "London", + "Paris", + "Tokyo", + "Mumbai", + "Dubai", + "Amsterdam", + "Seoul", + "Singapore", + "Istanbul", + ], + restaurantCategories: [ + "Italian", + "Chinese", + "Japanese", + "Mexican", + "Indian", + "Mediterranean", + "Caribbean", + "Cajun", + "German", + "Russian", + "Cuban", + "Organic", + "Tapas", + ], + restaurantReviews: [ + { text: "The food was exceptional, absolutely loved it!", rating: 5 }, + { text: "Delicious dishes and excellent service!", rating: 5 }, + { text: "The flavors were so rich and satisfying.", rating: 5 }, + { text: "Great ambiance and friendly staff.", rating: 5 }, + { text: "A delightful culinary experience!", rating: 5 }, + { + text: "Mouthwatering dishes that left me wanting more.", + rating: 5, + }, + { text: "Perfectly cooked and seasoned meals.", rating: 5 }, + { text: "Incredible presentation and top-notch taste.", rating: 5 }, + { text: "Good food and cozy atmosphere.", rating: 4 }, + { text: "Enjoyed the meal, would come back again.", rating: 4 }, + { text: "Tasty options and reasonable prices.", rating: 4 }, + { text: "Friendly staff, but the food was just okay.", rating: 3 }, + { text: "Decent experience, but nothing extraordinary.", rating: 3 }, + { text: "The food was average, could be better.", rating: 3 }, + { + text: "Service was slow, and the dishes were disappointing.", + rating: 2, + }, + { text: "Expected more, but left unsatisfied.", rating: 2 }, + { text: "Underwhelming taste and presentation.", rating: 2 }, + { text: "Disappointing experience overall.", rating: 2 }, + { + text: "The food was terrible, will never go back again.", + rating: 1, + }, + { + text: "Worst restaurant experience I've had so far.", + rating: 1, + }, + { text: "Avoid this place, the food was inedible.", rating: 1 }, + { text: "Unpleasant service and tasteless dishes.", rating: 1 }, + { text: "Simply outstanding! A culinary masterpiece.", rating: 5 }, + { text: "Couldn't get enough of the amazing flavors.", rating: 5 }, + { text: "Top-notch quality, worth every penny.", rating: 5 }, + { text: "Highly recommended for food enthusiasts.", rating: 5 }, + { text: "Exquisite dishes that pleased my taste buds.", rating: 5 }, + { text: "A gem of a place, impeccable in every aspect.", rating: 5 }, + { text: "Excellent selection of dishes on the menu.", rating: 5 }, + { text: "A fantastic dining experience overall.", rating: 5 }, + { + text: "The atmosphere was lovely, perfect for a date night.", + rating: 4, + }, + { text: "Good food, but the service could be improved.", rating: 4 }, + { + text: "Pleasantly surprised by the variety of flavors.", + rating: 4, + }, + { text: "Well-prepared dishes and friendly staff.", rating: 4 }, + { text: "Satisfying meals, suitable for a quick bite.", rating: 4 }, + { text: "The food was okay, nothing special.", rating: 3 }, + { + text: "Service could be better, but the taste was fine.", + rating: 3, + }, + { + text: "An average experience, didn't leave a lasting impression.", + rating: 3, + }, + { text: "Expected more value for the price.", rating: 2 }, + { text: "Mediocre taste and lackluster presentation.", rating: 2 }, + { + text: "Regret spending money on such a disappointing meal.", + rating: 2, + }, + { text: "Not up to par with my expectations.", rating: 2 }, + ], +}; diff --git a/nextjs-start/lib/utils.js b/nextjs-start/lib/utils.js new file mode 100644 index 000000000..22abdc852 --- /dev/null +++ b/nextjs-start/lib/utils.js @@ -0,0 +1,19 @@ +export function randomNumberBetween(min = 0, max = 1000) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +export function getRandomDateBefore(startingDate = new Date()) { + const randomNumberOfDays = randomNumberBetween(20, 80); + const randomDate = new Date( + startingDate - randomNumberOfDays * 24 * 60 * 60 * 1000 + ); + return randomDate; +} + +export function getRandomDateAfter(startingDate = new Date()) { + const randomNumberOfDays = randomNumberBetween(1, 19); + const randomDate = new Date( + startingDate.getTime() + randomNumberOfDays * 24 * 60 * 60 * 1000 + ); + return randomDate; +} diff --git a/nextjs-start/next.config.js b/nextjs-start/next.config.js new file mode 100644 index 000000000..5f2ec78f9 --- /dev/null +++ b/nextjs-start/next.config.js @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + serverActions: true, + }, +}; + +module.exports = nextConfig; diff --git a/nextjs-start/package.json b/nextjs-start/package.json new file mode 100644 index 000000000..40a53e539 --- /dev/null +++ b/nextjs-start/package.json @@ -0,0 +1,20 @@ +{ + "name": "my-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "firebase": "^10.0.0", + "next": "13.4.10", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "encoding": "^0.1.13" + } +} diff --git a/nextjs-start/public/add.svg b/nextjs-start/public/add.svg new file mode 100644 index 000000000..99224f885 --- /dev/null +++ b/nextjs-start/public/add.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/nextjs-start/public/filter.svg b/nextjs-start/public/filter.svg new file mode 100644 index 000000000..9d92763b6 --- /dev/null +++ b/nextjs-start/public/filter.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/nextjs-start/public/food.svg b/nextjs-start/public/food.svg new file mode 100644 index 000000000..96637edd1 --- /dev/null +++ b/nextjs-start/public/food.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/nextjs-start/public/friendly-eats.svg b/nextjs-start/public/friendly-eats.svg new file mode 100644 index 000000000..868aa8ded --- /dev/null +++ b/nextjs-start/public/friendly-eats.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nextjs-start/public/location.svg b/nextjs-start/public/location.svg new file mode 100644 index 000000000..9e1565cfa --- /dev/null +++ b/nextjs-start/public/location.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nextjs-start/public/next.svg b/nextjs-start/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/nextjs-start/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nextjs-start/public/price.svg b/nextjs-start/public/price.svg new file mode 100644 index 000000000..7c495263b --- /dev/null +++ b/nextjs-start/public/price.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/nextjs-start/public/profile.svg b/nextjs-start/public/profile.svg new file mode 100644 index 000000000..a0bc00773 --- /dev/null +++ b/nextjs-start/public/profile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nextjs-start/public/review.svg b/nextjs-start/public/review.svg new file mode 100644 index 000000000..8d0da31bd --- /dev/null +++ b/nextjs-start/public/review.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + diff --git a/nextjs-start/public/sortBy.svg b/nextjs-start/public/sortBy.svg new file mode 100644 index 000000000..06dd08332 --- /dev/null +++ b/nextjs-start/public/sortBy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nextjs-start/readme.md b/nextjs-start/readme.md new file mode 100644 index 000000000..9fb429bce --- /dev/null +++ b/nextjs-start/readme.md @@ -0,0 +1,14 @@ +### Starter code: Friendly Eats with Next.js + Firebase + +The codelab has the full instructions for how to run this project, but as a quick start: + +1. In your terminal, run: + +```sh +npm install +npm run dev +``` + +2. Open [http://localhost:3000](http://localhost:3000) in your browser. + +While the page should load, various features are not yet implemented as this is the starter code. The codelab explains the missing features and how to implement them. diff --git a/nextjs-start/src/app/actions.js b/nextjs-start/src/app/actions.js new file mode 100644 index 000000000..974ec54f7 --- /dev/null +++ b/nextjs-start/src/app/actions.js @@ -0,0 +1,5 @@ +"use server"; + +import { addReviewToRestaurant } from "@/lib/firebase/firestore.js"; + +export async function handleReviewFormSubmission() {} diff --git a/nextjs-start/src/app/api/route.js b/nextjs-start/src/app/api/route.js new file mode 100644 index 000000000..9f01e810f --- /dev/null +++ b/nextjs-start/src/app/api/route.js @@ -0,0 +1,2 @@ +// Replace this POST function +export async function POST() {} diff --git a/nextjs-start/src/app/favicon.ico b/nextjs-start/src/app/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/nextjs-start/src/app/favicon.ico differ diff --git a/nextjs-start/src/app/layout.js b/nextjs-start/src/app/layout.js new file mode 100644 index 000000000..b8d4dd541 --- /dev/null +++ b/nextjs-start/src/app/layout.js @@ -0,0 +1,25 @@ +import "@/src/app/styles.css"; +import Header from "@/components/Header.jsx"; +import getUser from "@/lib/getUser.js"; + +// 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"; + +export const metadata = { + title: "FriendlyEats", + description: + "FriendlyEats is a restaurant review website built with Next.js and Firebase.", +}; + +export default function RootLayout({ children }) { + return ( + + +
    + + {children} + + + ); +} diff --git a/nextjs-start/src/app/page.js b/nextjs-start/src/app/page.js new file mode 100644 index 000000000..f902937bf --- /dev/null +++ b/nextjs-start/src/app/page.js @@ -0,0 +1,25 @@ +import RestaurantListings from "@/components/RestaurantListings.jsx"; +import { getRestaurants } from "@/lib/firebase/firestore.js"; + +// 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"; + +// This line also forces this route to be server-side rendered +// export const revalidate = 0; + +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); + + return ( +
    + +
    + ); +} diff --git a/nextjs-start/src/app/restaurant/[id]/error.jsx b/nextjs-start/src/app/restaurant/[id]/error.jsx new file mode 100644 index 000000000..8ce09e05d --- /dev/null +++ b/nextjs-start/src/app/restaurant/[id]/error.jsx @@ -0,0 +1,23 @@ +"use client"; // Error components must be Client Components + +import { useEffect } from "react"; + +export default function Error({ error, reset }) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
    +

    Something went wrong!

    + +
    + ); +} diff --git a/nextjs-start/src/app/restaurant/[id]/page.jsx b/nextjs-start/src/app/restaurant/[id]/page.jsx new file mode 100644 index 000000000..12a9f207a --- /dev/null +++ b/nextjs-start/src/app/restaurant/[id]/page.jsx @@ -0,0 +1,27 @@ +import Restaurant from "@/components/Restaurant.jsx"; +import { + getRestaurantById, + getReviewsByRestaurantId, +} from "@/lib/firebase/firestore.js"; +import getUser from "@/lib/getUser.js"; + +export const dynamic = "force-dynamic"; + +export default async function Home({ params }) { + // This is a server component, we can access URL + // parameters via Next.js and download the data + // we need for this page + const restaurant = await getRestaurantById(params.id); + const reviews = await getReviewsByRestaurantId(params.id); + + return ( +
    + +
    + ); +} diff --git a/nextjs-start/src/app/styles.css b/nextjs-start/src/app/styles.css new file mode 100644 index 000000000..5e01d4409 --- /dev/null +++ b/nextjs-start/src/app/styles.css @@ -0,0 +1,535 @@ +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +body { + font-family: ui-sans-serif, system-ui, -apple-system; +} + +ul { + list-style-type: none; +} + +h2 { + font-weight: normal; +} + +dialog { + &[open] { + position: fixed; + width: 80vw; + height: 50vh; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 999; + + & article { + background-color: unset; + } + } + + & footer { + padding: 20px; + } +} + +footer { + & button { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), + 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), + 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + text-transform: uppercase; + font-size: 1rem; + outline: 0; + border: 0; + padding: 10px; + cursor: pointer; + } + + & .button--cancel { + color: rgb(178, 193, 212); + background-color: white; + } + + & .button--confirm { + background-color: rgb(255 111 0); + color: white; + } + + & menu { + display: flex; + justify-content: flex-end; + padding: 20px 0; + gap: 20px; + } +} + +header { + background-color: rgb(56 85 116); + color: white; + display: flex; + justify-content: space-between; + padding: 0.8rem; + align-items: center; + & img { + height: 2rem; + } + + & ul { + display: none; + position: absolute; + width: 220px; + z-index: 99; + } + & a { + text-decoration: none; + color: white; + } +} + +.logo { + display: flex; + align-items: end; + + & img { + margin-inline-end: 10px; + } + + color: white; + text-decoration: none; + font-size: 1.25rem; +} + +.menu { + display: inline-block; + position: relative; + padding: 15px 20px; + align-self: stretch; +} + +.menu ul { + /* display: block; */ + left: calc(-220px * 0.9); + color: rgb(42 72 101); + background-color: white; + box-shadow: 0 0 10px 0 rgb(0 0 0 / 50%); + + & li { + padding: 10px; + border-bottom: 1px solid rgb(42 72 101 / 0.25); + } + + & a { + font-weight: bold; + color: unset; + } + + & li:has(a):hover { + background-color: rgb(42 72 101 / 0.05); + } + + & a:visited { + color: unset; + } +} + +.menu:hover ul { + display: block; +} + +.profile { + display: flex; + /* align-items: center; + justify-content: center; */ + + & p { + display: flex; + align-items: center; + } +} + +.main__home { + background-color: rgb(178 193 212); + min-height: 100vh; +} + +.main__restaurant { + background-color: rgb(229 234 240); + min-height: 90vh; +} + +article { + margin: 0 auto; + background-color: rgb(229 234 240); + padding: 20px 40px; +} + +article { + width: 75%; +} + +.restaurants { + display: grid; + margin-top: 20px; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + + gap: 40px; + + & li { + background: white; + max-width: 300px; + } + + & a { + color: black; + display: flex; + flex-direction: column; + flex: 2 1 100%; + } + + & h2 { + font-weight: normal; + } +} + +.image-cover { + width: 100%; + height: 100%; + object-fit: cover; + max-height: 300px; + min-height: 300px; + position: relative; + + & img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + } +} + +a { + text-decoration: none; +} + +.restaurant__meta { + display: flex; + font-weight: 500; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.restaurant__details { + padding: 20px; +} + +.restaurant__rating { + padding: 5px 0; + display: flex; + align-items: center; + + & ul { + display: flex; + } + + & svg { + width: 2rem; + height: 2rem; + color: rgb(255 202 40); + } + + & span { + color: rgb(156 163 175); + } +} + +.img__section { + width: 100%; + height: 400px; + position: relative; + > img { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + max-width: unset; + } +} + +.details { + position: absolute; + bottom: 0; + padding: 20px; + color: white; + + & span { + color: inherit; + } +} + +.details__container { + --tw-gradient-from: #c60094 var(--tw-gradient-from-position); + --tw-gradient-to: rgb(56 85 116 / 0) var(--tw-gradient-to-position); + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); + background-image: linear-gradient(to top right, var(--tw-gradient-stops)); + position: absolute; + right: 0; + bottom: 0; + left: 0; + background: rgb(24 25 26 / 50%); + width: 100%; + height: 100%; +} + +.reviews { + & .review__item { + padding: 40px; + border-bottom: 1px solid rgb(156 163 175 / 0.25); + } + + & time { + font-size: 0.8rem; + color: darkgrey; + } +} + +.actions { + position: absolute; + z-index: 1; + bottom: -30px; + + right: 0; + display: flex; + justify-content: flex-end; + & img { + height: 4rem; + } + + .review { + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); + cursor: pointer; + background-color: rgb(255 202 40); + border-radius: 0.75rem; + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + } + + .add { + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), + 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); + background-color: rgb(255 143 0); + border-radius: 9999px; + cursor: pointer; + height: 4rem; + } + + .add input { + display: none; + } + + :where(.review, .add) { + margin: 0 30px; + } +} + +#review { + padding: 20px; + font-size: 17px; + border: none; + border-bottom: 2px solid rgb(255 111 0); + width: 100%; +} + +/* Thanks to: https://codepen.io/chris22smith/pen/MJzLJN */ +.star-rating { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; +} + +.radio-input { + position: fixed; + opacity: 0; + pointer-events: none; +} + +.radio-label { + cursor: pointer; + font-size: 0; + color: rgba(0, 0, 0, 0.2); + transition: color 0.1s ease-in-out; +} + +.radio-label:before { + content: "★"; + display: inline-block; + font-size: 32px; +} + +.radio-input:checked ~ .radio-label { + color: #ffc700; + color: gold; +} + +.radio-label:hover, +.radio-label:hover ~ .radio-label { + color: goldenrod; +} + +.radio-input:checked + .radio-label:hover, +.radio-input:checked + .radio-label:hover ~ .radio-label, +.radio-input:checked ~ .radio-label:hover, +.radio-input:checked ~ .radio-label:hover ~ .radio-label, +.radio-label:hover ~ .radio-input:checked ~ .radio-label { + color: darkgoldenrod; +} + +.average-rating { + position: relative; + appearance: none; + color: transparent; + width: auto; + display: inline-block; + vertical-align: baseline; + font-size: 25px; +} + +.average-rating::before { + --percent: calc(4.3 / 5 * 100%); + content: "★★★★★"; + position: absolute; + top: 0; + left: 0; + color: rgba(0, 0, 0, 0.2); + background: linear-gradient( + 90deg, + gold var(--percent), + rgba(0, 0, 0, 0.2) var(--percent) + ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.rating-picker { + display: flex; + flex-direction: row-reverse; + justify-content: center; +} + +.filter-menu { + background-color: white; + border-radius: 3px; + border-bottom: 1px solid rgb(27 58 87); + + & summary { + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + } + + & form { + display: flex; + flex-direction: column; + padding: 20px; + padding-bottom: 0; + } + + & label { + padding: 10px 0; + display: flex; + flex-direction: column; + flex-grow: 1; + + color: rgb(75 85 99); + font-size: 0.75rem; + line-height: 1rem; + } + + & img { + height: 4rem; + max-width: 100%; + } + + & form div { + display: flex; + gap: 10px; + } + + & select { + color: rgb(17 24 39); + font-size: 0.875rem; + line-height: 1.25rem; + padding-top: 1rem; + padding-bottom: 0.5rem; + padding-left: 0.625rem; + padding-right: 0.625rem; + border: 0; + border-bottom-width: 2px; + border-style: solid; + border-color: #e5e7eb; + } + + & p:first-child { + font-weight: 300; + font-size: 1.25rem; + line-height: 1.75rem; + margin-bottom: 2px; + } + + & p:last-child { + color: rgb(42 72 101); + font-weight: 600; + font-size: 0.875rem; + line-height: 1.25rem; + } +} + +.filter { + margin: 0 auto; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 30px 0px; + + & span { + font-weight: 500; + line-height: 1.25rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + background-color: rgb(71 98 130); + border-radius: 9999px; + color: white; + font-size: 0.95rem; + } + + & button { + cursor: pointer; + margin-left: 5px; + padding: 2px 10px; + color: white; + background-color: transparent; + outline: none; + border: none; + font-size: 0.8rem; + } +}