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}
+
+ {options.map((option, index) => (
+
+ {option === "" ? "All" : option}
+
+ ))}
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
Restaurants
+
Sorted by {filters.sort || "Rating"}
+
+
+
+
+
+
+
+ {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 (
+
+
+
+ Friendly Eats
+
+ {user ? (
+ <>
+
+
+
+ {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 (
+
+
+
+ 5 stars
+
+
+
+
+ 4 stars
+
+
+
+
+ 3 stars
+
+
+
+
+ 2 stars
+
+
+
+
+ 1 star
+
+
+ );
+};
+
+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 (
+
+
+
+
+ {userId && (
+
{
+ setIsOpen(!isOpen);
+ }}
+ src="/review.svg"
+ />
+ )}
+
handleRestaurantImage(event.target)}
+ htmlFor="upload-image"
+ className="add"
+ >
+
+
+
+
+
+
+
+
+
{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 }) => (
+
+
+
+);
+
+const ResturantDetails = ({ restaurant }) => (
+
+
{restaurant.name}
+
+
+
+);
+
+const RestaurantRating = ({ restaurant }) => (
+
+
{renderStars(restaurant.avgRating)}
+
({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 (
+
+
+
+ {restaurants?.map(restaurant => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+};
+
+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 (
+
+
+ {reviews.length > 0 ? (
+
+ {reviews.map(review => (
+
+
+ {renderStars(review.rating)}
+
+ {review.text}
+
+
+ {new Intl.DateTimeFormat("en-GB", {
+ dateStyle: "medium",
+ }).format(review.timestamp)}
+
+
+ ))}
+
+ ) : (
+
+ This restaurant has not been reviewed yet,{" "}
+ {!userId ? "first login and then" : ""} add your own
+ review!
+
+ )}
+
+
+ );
+};
+
+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}
+ updateField(type, "")}
+ >
+ X
+
+
+ );
+}
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!
+ reset()
+ }
+ >
+ Try again
+
+
+ );
+}
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;
+ }
+}