├── .eslintrc.json ├── .gitignore ├── .opensource └── project.json ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── nextjs-end ├── .eslintrc ├── .gitignore ├── .prettierrc.json ├── apphosting.yaml ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── friendlyeats-finished.webp ├── jsconfig.json ├── next.config.js ├── package-lock.json ├── package.json ├── public │ ├── add.svg │ ├── filter.svg │ ├── food.svg │ ├── friendly-eats.svg │ ├── location.svg │ ├── next.svg │ ├── price.svg │ ├── profile.svg │ ├── review.svg │ └── sortBy.svg ├── readme.md ├── src │ ├── app │ │ ├── actions.js │ │ ├── favicon.ico │ │ ├── layout.js │ │ ├── page.js │ │ ├── restaurant │ │ │ └── [id] │ │ │ │ ├── error.jsx │ │ │ │ └── page.jsx │ │ └── styles.css │ ├── components │ │ ├── Filters.jsx │ │ ├── Header.jsx │ │ ├── RatingPicker.jsx │ │ ├── Restaurant.jsx │ │ ├── RestaurantDetails.jsx │ │ ├── RestaurantListings.jsx │ │ ├── ReviewDialog.jsx │ │ ├── Reviews │ │ │ ├── Review.jsx │ │ │ ├── ReviewSummary.jsx │ │ │ ├── ReviewsList.jsx │ │ │ └── ReviewsListClient.jsx │ │ ├── Stars.jsx │ │ └── Tag.jsx │ └── lib │ │ ├── fakeRestaurants.js │ │ ├── firebase │ │ ├── auth.js │ │ ├── clientApp.js │ │ ├── firestore.js │ │ ├── serverApp.js │ │ └── storage.js │ │ ├── getUser.js │ │ ├── randomData.js │ │ └── utils.js └── storage.rules ├── nextjs-start ├── .eslintrc ├── .gitignore ├── .prettierrc.json ├── apphosting.yaml ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── jsconfig.json ├── next.config.js ├── package-lock.json ├── package.json ├── public │ ├── add.svg │ ├── filter.svg │ ├── food.svg │ ├── friendly-eats.svg │ ├── location.svg │ ├── next.svg │ ├── price.svg │ ├── profile.svg │ ├── review.svg │ └── sortBy.svg ├── readme.md ├── src │ ├── app │ │ ├── actions.js │ │ ├── favicon.ico │ │ ├── layout.js │ │ ├── page.js │ │ ├── restaurant │ │ │ └── [id] │ │ │ │ ├── error.jsx │ │ │ │ └── page.jsx │ │ └── styles.css │ ├── components │ │ ├── Filters.jsx │ │ ├── Header.jsx │ │ ├── RatingPicker.jsx │ │ ├── Restaurant.jsx │ │ ├── RestaurantDetails.jsx │ │ ├── RestaurantListings.jsx │ │ ├── ReviewDialog.jsx │ │ ├── Reviews │ │ │ ├── Review.jsx │ │ │ ├── ReviewSummary.jsx │ │ │ ├── ReviewsList.jsx │ │ │ └── ReviewsListClient.jsx │ │ ├── Stars.jsx │ │ └── Tag.jsx │ └── lib │ │ ├── fakeRestaurants.js │ │ ├── firebase │ │ ├── auth.js │ │ ├── clientApp.js │ │ ├── firestore.js │ │ ├── serverApp.js │ │ └── storage.js │ │ ├── getUser.js │ │ ├── randomData.js │ │ └── utils.js └── storage.rules ├── reactfire-end ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions │ ├── .eslintrc.js │ ├── .gitignore │ ├── index.js │ ├── package-lock.json │ └── package.json ├── hosting │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── images │ │ ├── FriendlyEats.png │ │ └── favicon.ico │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ ├── assets │ │ │ ├── add.svg │ │ │ ├── arrowDown.svg │ │ │ ├── filter.svg │ │ │ ├── food.svg │ │ │ ├── friendly-eats.svg │ │ │ ├── location.svg │ │ │ ├── menu.svg │ │ │ ├── price.svg │ │ │ ├── react.svg │ │ │ ├── review.svg │ │ │ └── sortBy.svg │ │ ├── components │ │ │ ├── filterModal.tsx │ │ │ ├── header.tsx │ │ │ ├── ratingModal.tsx │ │ │ └── restaurantCards.tsx │ │ ├── firebase-config.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── home.tsx │ │ │ └── restaurant.tsx │ │ └── vite-env.d.ts │ ├── styles │ │ └── main.css │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.d.ts │ ├── vite.config.js │ └── vite.config.ts ├── package.json ├── storage.rules └── tailwind.config.js ├── reactfire-start ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions │ ├── .eslintrc.js │ ├── .gitignore │ ├── index.js │ ├── package-lock.json │ └── package.json ├── hosting │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── images │ │ ├── FriendlyEats.png │ │ └── favicon.ico │ ├── index.html │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.tsx │ │ ├── assets │ │ │ ├── add.svg │ │ │ ├── arrowDown.svg │ │ │ ├── filter.svg │ │ │ ├── food.svg │ │ │ ├── friendly-eats.svg │ │ │ ├── location.svg │ │ │ ├── menu.svg │ │ │ ├── price.svg │ │ │ ├── react.svg │ │ │ ├── review.svg │ │ │ └── sortBy.svg │ │ ├── components │ │ │ ├── filterModal.tsx │ │ │ ├── header.tsx │ │ │ ├── ratingModal.tsx │ │ │ └── restaurantCards.tsx │ │ ├── firebase-config.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ ├── pages │ │ │ ├── home.tsx │ │ │ └── restaurant.tsx │ │ └── vite-env.d.ts │ ├── styles │ │ └── main.css │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.d.ts │ ├── vite.config.js │ └── vite.config.ts ├── package.json ├── storage.rules └── tailwind.config.js └── vanilla-js ├── docs └── finished_image.png ├── firebase-messaging-sw.js ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── images ├── favicon.ico ├── guy_fireats.png └── icons │ └── fireeats-192x192.png ├── index.html ├── manifest.json ├── package.json ├── scripts ├── FriendlyEats.Data.js ├── FriendlyEats.Mock.js ├── FriendlyEats.View.js └── FriendlyEats.js ├── steps ├── .gitignore ├── img │ ├── img1.png │ ├── img2.png │ ├── img3.png │ ├── img4.png │ ├── img5.png │ ├── img6.png │ └── img7.png └── index.lab.md ├── styles └── main.css ├── sw.js └── test.sh /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": "eslint:recommended", 6 | "rules": { 7 | "indent": [ 8 | "error", 9 | 4 10 | ], 11 | "linebreak-style": [ 12 | "error", 13 | "unix" 14 | ], 15 | "quotes": [ 16 | "error", 17 | "single" 18 | ], 19 | "semi": [ 20 | "error", 21 | "always" 22 | ], 23 | "curly": [ 24 | "error", 25 | "all" 26 | ], 27 | "consistent-this": [ 28 | "error", 29 | "that" 30 | ], 31 | "no-console": 0, 32 | "no-unused-vars": 0 33 | }, 34 | "globals": { 35 | "Navigo": true, 36 | "mdc": true, 37 | "importScripts": true, 38 | "FriendlyEats": true, 39 | "firebase": true, 40 | "Promise": true 41 | } 42 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.firebaserc 2 | **/.DS_Store 3 | **/*-debug.log 4 | 5 | **/node_modules/ 6 | **/package-lock.json 7 | **/pnpm-lock.yaml 8 | **/yarn.lock 9 | -------------------------------------------------------------------------------- /.opensource/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FriendlyEats Web - Firestore Codelab", 3 | "platforms": [ 4 | "Web" 5 | ], 6 | "content": "README.md", 7 | "tabs": [ 8 | { 9 | "title": "Codelab", 10 | "href": "https://codelabs.developers.google.com/codelabs/firestore-web" 11 | } 12 | ], 13 | "related": [ 14 | "firebase/quickstart-js", 15 | "firebase/friendlyeats-ios", 16 | "firebase/friendlyeats-android" 17 | ] 18 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | install: 5 | - npm install -g eslint 6 | - npm install 7 | script: 8 | - ./test.sh -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FriendlyEats (Web) 2 | 3 | ## Introduction 4 | 5 | FriendlyEats is a restaurant recommendation app built on Cloud Firestore. 6 | For more information about Firestore visit [the docs][firestore-docs]. 7 | 8 | This project is the starting point for the [Cloud Firestore Web Codelab][codelab], 9 | which will show you how to build the applications step-by-step. If you'd like to 10 | simply run the finished result, see the [quickstart app][quickstart]. 11 | 12 | 13 | 14 | ## Setup 15 | 16 | Follow the [Cloud Firestore Web Codelab][codelab] to set up this sample. 17 | 18 | ## License 19 | 20 | © Google, 2018. Licensed under an [Apache-2](./LICENSE) license. 21 | 22 | ## Build Status 23 | 24 | [![Build Status](https://travis-ci.org/firebase/friendlyeats-web.svg?branch=master)](https://travis-ci.org/firebase/friendlyeats-web) 25 | 26 | [codelab]: https://codelabs.developers.google.com/codelabs/firestore-web 27 | [quickstart]: https://github.com/firebase/quickstart-js/tree/master/firestore 28 | [firestore-docs]: https://firebase.google.com/docs/firestore/ -------------------------------------------------------------------------------- /nextjs-end/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "next", "prettier"], 3 | "rules": { 4 | "@next/next/no-img-element": "off" 5 | } 6 | } -------------------------------------------------------------------------------- /nextjs-end/.gitignore: -------------------------------------------------------------------------------- 1 | lib/firebase/config.js 2 | .next/ 3 | .firebase/ 4 | node_modules/ 5 | .env -------------------------------------------------------------------------------- /nextjs-end/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } 4 | -------------------------------------------------------------------------------- /nextjs-end/apphosting.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | # Set this with firebase apphosting:secrets:set 3 | - variable: GEMINI_API_KEY 4 | secret: gemini-api-key 5 | runConfig: 6 | minInstances: 0 7 | maxInstances: 2 8 | -------------------------------------------------------------------------------- /nextjs-end/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "auth": { 4 | "port": 9099 5 | }, 6 | "functions": { 7 | "port": 5001 8 | }, 9 | "firestore": { 10 | "port": 8080 11 | }, 12 | "database": { 13 | "port": 9000 14 | }, 15 | "storage": { 16 | "port": 9199 17 | }, 18 | "ui": { 19 | "enabled": true 20 | }, 21 | "singleProjectMode": true, 22 | "hosting": { 23 | "port": 5000 24 | } 25 | }, 26 | "functions": [ 27 | { 28 | "source": "functions", 29 | "codebase": "default", 30 | "ignore": [ 31 | "node_modules", 32 | ".git", 33 | "firebase-debug.log", 34 | "firebase-debug.*.log" 35 | ] 36 | } 37 | ], 38 | "firestore": { 39 | "rules": "firestore.rules", 40 | "indexes": "firestore.indexes.json" 41 | }, 42 | "hosting": { 43 | "source": ".", 44 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 45 | "frameworksBackend": { 46 | "region": "us-central1" 47 | } 48 | }, 49 | "storage": { 50 | "rules": "storage.rules" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /nextjs-end/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "restaurants", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "category", 9 | "order": "ASCENDING" 10 | }, 11 | { 12 | "fieldPath": "avgRating", 13 | "order": "DESCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "restaurants", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "category", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "numRatings", 27 | "order": "DESCENDING" 28 | } 29 | ] 30 | }, 31 | { 32 | "collectionGroup": "restaurants", 33 | "queryScope": "COLLECTION", 34 | "fields": [ 35 | { 36 | "fieldPath": "category", 37 | "order": "ASCENDING" 38 | }, 39 | { 40 | "fieldPath": "price", 41 | "order": "ASCENDING" 42 | } 43 | ] 44 | }, 45 | { 46 | "collectionGroup": "restaurants", 47 | "queryScope": "COLLECTION", 48 | "fields": [ 49 | { 50 | "fieldPath": "city", 51 | "order": "ASCENDING" 52 | }, 53 | { 54 | "fieldPath": "avgRating", 55 | "order": "DESCENDING" 56 | } 57 | ] 58 | }, 59 | { 60 | "collectionGroup": "restaurants", 61 | "queryScope": "COLLECTION", 62 | "fields": [ 63 | { 64 | "fieldPath": "city", 65 | "order": "ASCENDING" 66 | }, 67 | { 68 | "fieldPath": "numRatings", 69 | "order": "DESCENDING" 70 | } 71 | ] 72 | }, 73 | { 74 | "collectionGroup": "restaurants", 75 | "queryScope": "COLLECTION", 76 | "fields": [ 77 | { 78 | "fieldPath": "city", 79 | "order": "ASCENDING" 80 | }, 81 | { 82 | "fieldPath": "price", 83 | "order": "ASCENDING" 84 | } 85 | ] 86 | }, 87 | { 88 | "collectionGroup": "restaurants", 89 | "queryScope": "COLLECTION", 90 | "fields": [ 91 | { 92 | "fieldPath": "price", 93 | "order": "ASCENDING" 94 | }, 95 | { 96 | "fieldPath": "avgRating", 97 | "order": "DESCENDING" 98 | } 99 | ] 100 | }, 101 | { 102 | "collectionGroup": "restaurants", 103 | "queryScope": "COLLECTION", 104 | "fields": [ 105 | { 106 | "fieldPath": "price", 107 | "order": "ASCENDING" 108 | }, 109 | { 110 | "fieldPath": "numRatings", 111 | "order": "DESCENDING" 112 | } 113 | ] 114 | } 115 | ], 116 | "fieldOverrides": [] 117 | } 118 | -------------------------------------------------------------------------------- /nextjs-end/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | 4 | // Determine if the value of the field "key" is the same 5 | // before and after the request. 6 | function unchanged(key) { 7 | return (key in resource.data) 8 | && (key in request.resource.data) 9 | && (resource.data[key] == request.resource.data[key]); 10 | } 11 | 12 | match /databases/{database}/documents { 13 | // Restaurants: 14 | // - Authenticated user can read 15 | // - Authenticated user can create/update (for demo purposes only) 16 | // - Updates are allowed if no fields are added and name is unchanged 17 | // - Deletes are not allowed (default) 18 | match /restaurants/{restaurantId} { 19 | allow read; 20 | allow create: if request.auth != null; 21 | allow update: if request.auth != null 22 | && unchanged("name"); 23 | 24 | // Ratings: 25 | // - Authenticated user can read 26 | // - Authenticated user can create if userId matches 27 | // - Deletes and updates are not allowed (default) 28 | match /ratings/{ratingId} { 29 | allow read; 30 | allow create: if request.auth != null; 31 | allow update: if request.auth != null 32 | && request.resource.data.userId == request.auth.uid; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /nextjs-end/friendlyeats-finished.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/nextjs-end/friendlyeats-finished.webp -------------------------------------------------------------------------------- /nextjs-end/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /nextjs-end/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | eslint: { 4 | ignoreDuringBuilds: true, 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /nextjs-end/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "npm run lint:next && npm:lint:prettier", 10 | "lint:next": "next lint", 11 | "lint:prettier": "prettier --check --ignore-path .gitignore .", 12 | "lint:fix": "npm run lint:next -- --fix && npm run lint:prettier -- --write" 13 | }, 14 | "dependencies": { 15 | "@genkit-ai/googleai": "^1.5.0", 16 | "cookies-next": "^5.1.0", 17 | "firebase": "^11.6.0", 18 | "genkit": "^1.5.0", 19 | "next": "15.1.6", 20 | "react": "19.0.0", 21 | "react-dom": "19.0.0", 22 | "server-only": "^0.0.1" 23 | }, 24 | "browser": { 25 | "fs": false, 26 | "os": false, 27 | "path": false, 28 | "child_process": false, 29 | "net": false, 30 | "tls": false 31 | }, 32 | "devDependencies": { 33 | "@next/eslint-plugin-next": "15.1.6", 34 | "eslint": "^8.57.1", 35 | "eslint-config-next": "15.1.6", 36 | "eslint-config-prettier": "^9.1.0", 37 | "prettier": "^3.5.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /nextjs-end/public/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /nextjs-end/public/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /nextjs-end/public/food.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /nextjs-end/public/location.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /nextjs-end/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nextjs-end/public/price.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /nextjs-end/public/profile.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nextjs-end/public/review.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | -------------------------------------------------------------------------------- /nextjs-end/public/sortBy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /nextjs-end/readme.md: -------------------------------------------------------------------------------- 1 | ### Friendly Eats with Next.js + Firebase 2 | 3 | The codelab has the full instructions, but as a quick start, you can do this. 4 | 5 | #### Run the application 6 | 7 | 1. In your terminal, run: 8 | 9 | ```sh 10 | firebase emulators:start --project demo-codelab-nextjs 11 | ``` 12 | 13 | 2. Copy the file `lib/firebase/config-copy.js` to `lib/firebase/config.js` and fill in the values from the Firebase console. 14 | 15 | 3. In a new terminal tab/window, run: 16 | 17 | ```sh 18 | npm i 19 | npm run dev 20 | ``` 21 | 22 | 4. In your browser, open the URL: `http://localhost:3000` 23 | 24 | #### Use the application 25 | 26 | 1. While on `http://localhost:3000/` within your browser, click the "Sign in" button in the top right corner and sign in. 27 | 2. In the dropdown menu in the top right menu, select "Add sample restaurants". 28 | -------------------------------------------------------------------------------- /nextjs-end/src/app/actions.js: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { addReviewToRestaurant } from "@/src/lib/firebase/firestore.js"; 4 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp.js"; 5 | import { getFirestore } from "firebase/firestore"; 6 | 7 | // This is a Server Action 8 | // https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions 9 | export async function handleReviewFormSubmission(data) { 10 | const { firebaseServerApp } = await getAuthenticatedAppForUser(); 11 | const db = getFirestore(firebaseServerApp); 12 | 13 | await addReviewToRestaurant(db, data.get("restaurantId"), { 14 | text: data.get("text"), 15 | rating: data.get("rating"), 16 | 17 | // This came from a hidden form field 18 | userId: data.get("userId"), 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /nextjs-end/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/nextjs-end/src/app/favicon.ico -------------------------------------------------------------------------------- /nextjs-end/src/app/layout.js: -------------------------------------------------------------------------------- 1 | import "@/src/app/styles.css"; 2 | import Header from "@/src/components/Header.jsx"; 3 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; 4 | // Force next.js to treat this route as server-side rendered 5 | // Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it 6 | export const dynamic = "force-dynamic"; 7 | 8 | export const metadata = { 9 | title: "FriendlyEats", 10 | description: 11 | "FriendlyEats is a restaurant review website built with Next.js and Firebase.", 12 | }; 13 | 14 | export default async function RootLayout({ children }) { 15 | const { currentUser } = await getAuthenticatedAppForUser(); 16 | return ( 17 | 18 | 19 |
20 | 21 |
{children}
22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /nextjs-end/src/app/page.js: -------------------------------------------------------------------------------- 1 | import RestaurantListings from "@/src/components/RestaurantListings.jsx"; 2 | import { getRestaurants } from "@/src/lib/firebase/firestore.js"; 3 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp.js"; 4 | import { getFirestore } from "firebase/firestore"; 5 | 6 | // Force next.js to treat this route as server-side rendered 7 | // Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it 8 | 9 | export const dynamic = "force-dynamic"; 10 | 11 | // This line also forces this route to be server-side rendered 12 | // export const revalidate = 0; 13 | 14 | export default async function Home(props) { 15 | const searchParams = await props.searchParams; 16 | // Using seachParams which Next.js provides, allows the filtering to happen on the server-side, for example: 17 | // ?city=London&category=Indian&sort=Review 18 | const { firebaseServerApp } = await getAuthenticatedAppForUser(); 19 | const restaurants = await getRestaurants( 20 | getFirestore(firebaseServerApp), 21 | searchParams 22 | ); 23 | return ( 24 |
25 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /nextjs-end/src/app/restaurant/[id]/error.jsx: -------------------------------------------------------------------------------- 1 | "use client"; // Error components must be Client Components 2 | 3 | import { useEffect } from "react"; 4 | 5 | export default function Error({ error, reset }) { 6 | useEffect(() => { 7 | console.error(error); 8 | }, [error]); 9 | 10 | return ( 11 |
12 |

Something went wrong!

13 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /nextjs-end/src/app/restaurant/[id]/page.jsx: -------------------------------------------------------------------------------- 1 | import Restaurant from "@/src/components/Restaurant.jsx"; 2 | import { Suspense } from "react"; 3 | import { getRestaurantById } from "@/src/lib/firebase/firestore.js"; 4 | import { 5 | getAuthenticatedAppForUser, 6 | getAuthenticatedAppForUser as getUser, 7 | } from "@/src/lib/firebase/serverApp.js"; 8 | import ReviewsList, { 9 | ReviewsListSkeleton, 10 | } from "@/src/components/Reviews/ReviewsList"; 11 | import { 12 | GeminiSummary, 13 | GeminiSummarySkeleton, 14 | } from "@/src/components/Reviews/ReviewSummary"; 15 | import { getFirestore } from "firebase/firestore"; 16 | 17 | export default async function Home(props) { 18 | const params = await props.params; 19 | const { currentUser } = await getUser(); 20 | const { firebaseServerApp } = await getAuthenticatedAppForUser(); 21 | const restaurant = await getRestaurantById( 22 | getFirestore(firebaseServerApp), 23 | params.id 24 | ); 25 | 26 | return ( 27 |
28 | 33 | }> 34 | 35 | 36 | 37 | } 39 | > 40 | 41 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Filters.jsx: -------------------------------------------------------------------------------- 1 | // The filters shown on the restaurant listings page 2 | 3 | import Tag from "@/src/components/Tag.jsx"; 4 | 5 | function FilterSelect({ label, options, value, onChange, name, icon }) { 6 | return ( 7 |
8 | {label} 9 | 19 |
20 | ); 21 | } 22 | 23 | export default function Filters({ filters, setFilters }) { 24 | const handleSelectionChange = (event, name) => { 25 | setFilters((prevFilters) => ({ 26 | ...prevFilters, 27 | [name]: event.target.value, 28 | })); 29 | }; 30 | 31 | const updateField = (type, value) => { 32 | setFilters({ ...filters, [type]: value }); 33 | }; 34 | 35 | return ( 36 |
37 |
38 | 39 | filter 40 |
41 |

Restaurants

42 |

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

43 |
44 |
45 | 46 |
{ 49 | event.preventDefault(); 50 | event.target.parentNode.removeAttribute("open"); 51 | }} 52 | > 53 | handleSelectionChange(event, "category")} 73 | name="category" 74 | icon="/food.svg" 75 | /> 76 | 77 | handleSelectionChange(event, "city")} 95 | name="city" 96 | icon="/location.svg" 97 | /> 98 | 99 | handleSelectionChange(event, "price")} 104 | name="price" 105 | icon="/price.svg" 106 | /> 107 | 108 | handleSelectionChange(event, "sort")} 113 | name="sort" 114 | icon="/sortBy.svg" 115 | /> 116 | 117 |
118 | 119 | 133 | 136 | 137 |
138 | 139 |
140 | 141 |
142 | {Object.entries(filters).map(([type, value]) => { 143 | // The main filter bar already specifies what 144 | // sorting is being used. So skip showing the 145 | // sorting as a 'tag' 146 | if (type == "sort" || value == "") { 147 | return null; 148 | } 149 | return ( 150 | 156 | ); 157 | })} 158 |
159 |
160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect } from "react"; 3 | import Link from "next/link"; 4 | import { 5 | signInWithGoogle, 6 | signOut, 7 | onIdTokenChanged, 8 | } from "@/src/lib/firebase/auth.js"; 9 | import { addFakeRestaurantsAndReviews } from "@/src/lib/firebase/firestore.js"; 10 | import { setCookie, deleteCookie } from "cookies-next"; 11 | 12 | function useUserSession(initialUser) { 13 | useEffect(() => { 14 | return onIdTokenChanged(async (user) => { 15 | if (user) { 16 | const idToken = await user.getIdToken(); 17 | await setCookie("__session", idToken); 18 | } else { 19 | await deleteCookie("__session"); 20 | } 21 | if (initialUser?.uid === user?.uid) { 22 | return; 23 | } 24 | window.location.reload(); 25 | }); 26 | }, [initialUser]); 27 | 28 | return initialUser; 29 | } 30 | 31 | export default function Header({ initialUser }) { 32 | const user = useUserSession(initialUser); 33 | 34 | const handleSignOut = (event) => { 35 | event.preventDefault(); 36 | signOut(); 37 | }; 38 | 39 | const handleSignIn = (event) => { 40 | event.preventDefault(); 41 | signInWithGoogle(); 42 | }; 43 | 44 | return ( 45 |
46 | 47 | FriendlyEats 48 | Friendly Eats 49 | 50 | {user ? ( 51 | <> 52 |
53 |

54 | {user.email} 59 | {user.displayName} 60 |

61 | 62 |
63 | ... 64 | 79 |
80 |
81 | 82 | ) : ( 83 |
84 | 85 | A placeholder user image 86 | Sign In with Google 87 | 88 |
89 | )} 90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /nextjs-end/src/components/RatingPicker.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // A HTML and CSS only rating picker thanks to: https://codepen.io/chris22smith/pen/MJzLJN 4 | 5 | const RatingPicker = () => { 6 | return ( 7 |

8 | 15 | 18 | 19 | 26 | 29 | 30 | 37 | 40 | 41 | 48 | 51 | 52 | 59 | 62 |

63 | ); 64 | }; 65 | 66 | export default RatingPicker; 67 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Restaurant.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // This components shows one individual restaurant 4 | // It receives data from src/app/restaurant/[id]/page.jsx 5 | 6 | import { React, useState, useEffect, Suspense } from "react"; 7 | import dynamic from "next/dynamic"; 8 | import { getRestaurantSnapshotById } from "@/src/lib/firebase/firestore.js"; 9 | import { useUser } from "@/src/lib/getUser"; 10 | import RestaurantDetails from "@/src/components/RestaurantDetails.jsx"; 11 | import { updateRestaurantImage } from "@/src/lib/firebase/storage.js"; 12 | 13 | const ReviewDialog = dynamic(() => import("@/src/components/ReviewDialog.jsx")); 14 | 15 | export default function Restaurant({ 16 | id, 17 | initialRestaurant, 18 | initialUserId, 19 | children, 20 | }) { 21 | const [restaurantDetails, setRestaurantDetails] = useState(initialRestaurant); 22 | const [isOpen, setIsOpen] = useState(false); 23 | 24 | // 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 25 | const userId = useUser()?.uid || initialUserId; 26 | const [review, setReview] = useState({ 27 | rating: 0, 28 | text: "", 29 | }); 30 | 31 | const onChange = (value, name) => { 32 | setReview({ ...review, [name]: value }); 33 | }; 34 | 35 | async function handleRestaurantImage(target) { 36 | const image = target.files ? target.files[0] : null; 37 | if (!image) { 38 | return; 39 | } 40 | 41 | const imageURL = await updateRestaurantImage(id, image); 42 | setRestaurantDetails({ ...restaurantDetails, photo: imageURL }); 43 | } 44 | 45 | const handleClose = () => { 46 | setIsOpen(false); 47 | setReview({ rating: 0, text: "" }); 48 | }; 49 | 50 | useEffect(() => { 51 | return getRestaurantSnapshotById(id, (data) => { 52 | setRestaurantDetails(data); 53 | }); 54 | }, [id]); 55 | 56 | return ( 57 | <> 58 | 65 | {children} 66 | 67 | {userId && ( 68 | Loading...

}> 69 | 77 |
78 | )} 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /nextjs-end/src/components/RestaurantDetails.jsx: -------------------------------------------------------------------------------- 1 | // This component shows restaurant metadata, and offers some actions to the user like uploading a new restaurant image, and adding a review. 2 | 3 | import React from "react"; 4 | import renderStars from "@/src/components/Stars.jsx"; 5 | 6 | const RestaurantDetails = ({ 7 | restaurant, 8 | userId, 9 | handleRestaurantImage, 10 | setIsOpen, 11 | isOpen, 12 | children, 13 | }) => { 14 | return ( 15 |
16 | {restaurant.name} 17 | 18 |
19 | {userId && ( 20 | review { 24 | setIsOpen(!isOpen); 25 | }} 26 | src="/review.svg" 27 | /> 28 | )} 29 | 43 |
44 | 45 |
46 |
47 |

{restaurant.name}

48 | 49 |
50 |
    {renderStars(restaurant.avgRating)}
51 | 52 | ({restaurant.numRatings}) 53 |
54 | 55 |

56 | {restaurant.category} | {restaurant.city} 57 |

58 |

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

59 | {children} 60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | export default RestaurantDetails; 67 | -------------------------------------------------------------------------------- /nextjs-end/src/components/RestaurantListings.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // This components handles the restaurant listings page 4 | // It receives data from src/app/page.jsx, such as the initial restaurants and search params from the URL 5 | 6 | import Link from "next/link"; 7 | import { React, useState, useEffect } from "react"; 8 | import { useRouter } from "next/navigation"; 9 | import renderStars from "@/src/components/Stars.jsx"; 10 | import { getRestaurantsSnapshot } from "@/src/lib/firebase/firestore.js"; 11 | import Filters from "@/src/components/Filters.jsx"; 12 | 13 | const RestaurantItem = ({ restaurant }) => ( 14 |
  • 15 | 16 | 17 | 18 |
  • 19 | ); 20 | 21 | const ActiveResturant = ({ restaurant }) => ( 22 |
    23 | 24 | 25 |
    26 | ); 27 | 28 | const ImageCover = ({ photo, name }) => ( 29 |
    30 | {name} 31 |
    32 | ); 33 | 34 | const ResturantDetails = ({ restaurant }) => ( 35 |
    36 |

    {restaurant.name}

    37 | 38 | 39 |
    40 | ); 41 | 42 | const RestaurantRating = ({ restaurant }) => ( 43 |
    44 | 45 | ({restaurant.numRatings}) 46 |
    47 | ); 48 | 49 | const RestaurantMetadata = ({ restaurant }) => ( 50 |
    51 |

    52 | {restaurant.category} | {restaurant.city} 53 |

    54 |

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

    55 |
    56 | ); 57 | 58 | export default function RestaurantListings({ 59 | initialRestaurants, 60 | searchParams, 61 | }) { 62 | const router = useRouter(); 63 | 64 | // The initial filters are the search params from the URL, useful for when the user refreshes the page 65 | const initialFilters = { 66 | city: searchParams.city || "", 67 | category: searchParams.category || "", 68 | price: searchParams.price || "", 69 | sort: searchParams.sort || "", 70 | }; 71 | 72 | const [restaurants, setRestaurants] = useState(initialRestaurants); 73 | const [filters, setFilters] = useState(initialFilters); 74 | 75 | useEffect(() => { 76 | routerWithFilters(router, filters); 77 | }, [router, filters]); 78 | 79 | useEffect(() => { 80 | return getRestaurantsSnapshot((data) => { 81 | setRestaurants(data); 82 | }, filters); 83 | }, [filters]); 84 | 85 | return ( 86 |
    87 | 88 |
      89 | {restaurants.map((restaurant) => ( 90 | 91 | ))} 92 |
    93 |
    94 | ); 95 | } 96 | 97 | function routerWithFilters(router, filters) { 98 | const queryParams = new URLSearchParams(); 99 | 100 | for (const [key, value] of Object.entries(filters)) { 101 | if (value !== undefined && value !== "") { 102 | queryParams.append(key, value); 103 | } 104 | } 105 | 106 | const queryString = queryParams.toString(); 107 | router.push(`?${queryString}`); 108 | } 109 | -------------------------------------------------------------------------------- /nextjs-end/src/components/ReviewDialog.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // This components handles the review dialog and uses a next.js feature known as Server Actions to handle the form submission 4 | 5 | import { useLayoutEffect, useRef } from "react"; 6 | import RatingPicker from "@/src/components/RatingPicker.jsx"; 7 | import { handleReviewFormSubmission } from "@/src/app/actions.js"; 8 | 9 | const ReviewDialog = ({ 10 | isOpen, 11 | handleClose, 12 | review, 13 | onChange, 14 | userId, 15 | id, 16 | }) => { 17 | const dialog = useRef(); 18 | 19 | // dialogs only render their backdrop when called with `showModal` 20 | useLayoutEffect(() => { 21 | if (isOpen) { 22 | dialog.current.showModal(); 23 | } else { 24 | dialog.current.close(); 25 | } 26 | }, [isOpen, dialog]); 27 | 28 | const handleClick = (e) => { 29 | // close if clicked outside the modal 30 | if (e.target === dialog.current) { 31 | handleClose(); 32 | } 33 | }; 34 | 35 | return ( 36 | 37 |
    { 40 | handleClose(); 41 | }} 42 | > 43 |
    44 |

    Add your review

    45 |
    46 |
    47 | 48 | 49 |

    50 | onChange(e.target.value, "text")} 58 | /> 59 |

    60 | 61 | 62 | 63 |
    64 |
    65 | 66 | 74 | 77 | 78 |
    79 |
    80 |
    81 | ); 82 | }; 83 | 84 | export default ReviewDialog; 85 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Reviews/Review.jsx: -------------------------------------------------------------------------------- 1 | import renderStars from "@/src/components/Stars.jsx"; 2 | 3 | export function Review({ rating, text, timestamp }) { 4 | return ( 5 |
  • 6 | 7 |

    {text}

    8 | 9 | 14 |
  • 15 | ); 16 | } 17 | 18 | export function ReviewSkeleton() { 19 | return ( 20 |
  • 21 |
    22 |
    29 |
    30 |
    37 |

    {" "}

    38 |
  • 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Reviews/ReviewSummary.jsx: -------------------------------------------------------------------------------- 1 | import { gemini20Flash, googleAI } from "@genkit-ai/googleai"; 2 | import { genkit } from "genkit"; 3 | import { getReviewsByRestaurantId } from "@/src/lib/firebase/firestore.js"; 4 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; 5 | import { getFirestore } from "firebase/firestore"; 6 | 7 | export async function GeminiSummary({ restaurantId }) { 8 | const { firebaseServerApp } = await getAuthenticatedAppForUser(); 9 | const reviews = await getReviewsByRestaurantId( 10 | getFirestore(firebaseServerApp), 11 | restaurantId 12 | ); 13 | 14 | const reviewSeparator = "@"; 15 | const prompt = ` 16 | Based on the following restaurant reviews, 17 | where each review is separated by a '${reviewSeparator}' character, 18 | create a one-sentence summary of what people think of the restaurant. 19 | 20 | Here are the reviews: ${reviews.map((review) => review.text).join(reviewSeparator)} 21 | `; 22 | 23 | try { 24 | if (!process.env.GEMINI_API_KEY) { 25 | // Make sure GEMINI_API_KEY environment variable is set: 26 | // https://firebase.google.com/docs/genkit/get-started 27 | throw new Error( 28 | 'GEMINI_API_KEY not set. Set it with "firebase apphosting:secrets:set GEMINI_API_KEY"' 29 | ); 30 | } 31 | 32 | // Configure a Genkit instance. 33 | const ai = genkit({ 34 | plugins: [googleAI()], 35 | model: gemini20Flash, // set default model 36 | }); 37 | const { text } = await ai.generate(prompt); 38 | 39 | return ( 40 |
    41 |

    {text}

    42 |

    ✨ Summarized with Gemini

    43 |
    44 | ); 45 | } catch (e) { 46 | console.error(e); 47 | return

    Error summarizing reviews.

    ; 48 | } 49 | } 50 | 51 | export function GeminiSummarySkeleton() { 52 | return ( 53 |
    54 |

    ✨ Summarizing reviews with Gemini...

    55 |
    56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Reviews/ReviewsList.jsx: -------------------------------------------------------------------------------- 1 | // This component handles the list of reviews for a given restaurant 2 | 3 | import React from "react"; 4 | import { getReviewsByRestaurantId } from "@/src/lib/firebase/firestore.js"; 5 | import ReviewsListClient from "@/src/components/Reviews/ReviewsListClient"; 6 | import { ReviewSkeleton } from "@/src/components/Reviews/Review"; 7 | import { getFirestore } from "firebase/firestore"; 8 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; 9 | 10 | export default async function ReviewsList({ restaurantId, userId }) { 11 | const { firebaseServerApp } = await getAuthenticatedAppForUser(); 12 | const reviews = await getReviewsByRestaurantId( 13 | getFirestore(firebaseServerApp), 14 | restaurantId 15 | ); 16 | 17 | return ( 18 | 23 | ); 24 | } 25 | 26 | export function ReviewsListSkeleton({ numReviews }) { 27 | return ( 28 |
    29 |
      30 |
        31 | {Array(numReviews) 32 | .fill(0) 33 | .map((value, index) => ( 34 | 35 | ))} 36 |
      37 |
    38 |
    39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Reviews/ReviewsListClient.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useEffect } from "react"; 4 | import { getReviewsSnapshotByRestaurantId } from "@/src/lib/firebase/firestore.js"; 5 | import { Review } from "@/src/components/Reviews/Review"; 6 | 7 | export default function ReviewsListClient({ 8 | initialReviews, 9 | restaurantId, 10 | userId, 11 | }) { 12 | const [reviews, setReviews] = useState(initialReviews); 13 | 14 | useEffect(() => { 15 | return getReviewsSnapshotByRestaurantId(restaurantId, (data) => { 16 | setReviews(data); 17 | }); 18 | }, [restaurantId]); 19 | return ( 20 |
    21 |
      22 | {reviews.length > 0 ? ( 23 |
        24 | {reviews.map((review) => ( 25 | 31 | ))} 32 |
      33 | ) : ( 34 |

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

      38 | )} 39 |
    40 |
    41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Stars.jsx: -------------------------------------------------------------------------------- 1 | // This component displays star ratings 2 | 3 | export default function renderStars(avgRating) { 4 | const arr = []; 5 | for (let i = 0; i < 5; i++) { 6 | if (i < Math.floor(avgRating)) { 7 | arr.push( 8 |
  • 9 | 15 | 20 | 21 |
  • 22 | ); 23 | } else { 24 | arr.push( 25 |
  • 26 | 34 | 39 | 40 |
  • 41 | ); 42 | } 43 | } 44 | return arr; 45 | } 46 | -------------------------------------------------------------------------------- /nextjs-end/src/components/Tag.jsx: -------------------------------------------------------------------------------- 1 | // A tag is shown under the filter bar when a filter is selected. 2 | // Tags show what filters have been selected 3 | // On click, the tag is removed and the filter is reset 4 | 5 | export default function Tag({ type, value, updateField }) { 6 | return ( 7 | 8 | {value} 9 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /nextjs-end/src/lib/fakeRestaurants.js: -------------------------------------------------------------------------------- 1 | import { 2 | randomNumberBetween, 3 | getRandomDateAfter, 4 | getRandomDateBefore, 5 | } from "@/src/lib/utils.js"; 6 | import { randomData } from "@/src/lib/randomData.js"; 7 | 8 | import { Timestamp } from "firebase/firestore"; 9 | 10 | export async function generateFakeRestaurantsAndReviews() { 11 | const restaurantsToAdd = 5; 12 | const data = []; 13 | 14 | for (let i = 0; i < restaurantsToAdd; i++) { 15 | const restaurantTimestamp = Timestamp.fromDate(getRandomDateBefore()); 16 | 17 | const ratingsData = []; 18 | 19 | // Generate a random number of ratings/reviews for this restaurant 20 | for (let j = 0; j < randomNumberBetween(0, 5); j++) { 21 | const ratingTimestamp = Timestamp.fromDate( 22 | getRandomDateAfter(restaurantTimestamp.toDate()) 23 | ); 24 | 25 | const ratingData = { 26 | rating: 27 | randomData.restaurantReviews[ 28 | randomNumberBetween(0, randomData.restaurantReviews.length - 1) 29 | ].rating, 30 | text: randomData.restaurantReviews[ 31 | randomNumberBetween(0, randomData.restaurantReviews.length - 1) 32 | ].text, 33 | userId: `User #${randomNumberBetween()}`, 34 | timestamp: ratingTimestamp, 35 | }; 36 | 37 | ratingsData.push(ratingData); 38 | } 39 | 40 | const avgRating = ratingsData.length 41 | ? ratingsData.reduce( 42 | (accumulator, currentValue) => accumulator + currentValue.rating, 43 | 0 44 | ) / ratingsData.length 45 | : 0; 46 | 47 | const restaurantData = { 48 | category: 49 | randomData.restaurantCategories[ 50 | randomNumberBetween(0, randomData.restaurantCategories.length - 1) 51 | ], 52 | name: randomData.restaurantNames[ 53 | randomNumberBetween(0, randomData.restaurantNames.length - 1) 54 | ], 55 | avgRating, 56 | city: randomData.restaurantCities[ 57 | randomNumberBetween(0, randomData.restaurantCities.length - 1) 58 | ], 59 | numRatings: ratingsData.length, 60 | sumRating: ratingsData.reduce( 61 | (accumulator, currentValue) => accumulator + currentValue.rating, 62 | 0 63 | ), 64 | price: randomNumberBetween(1, 4), 65 | photo: `https://storage.googleapis.com/firestorequickstarts.appspot.com/food_${randomNumberBetween( 66 | 1, 67 | 22 68 | )}.png`, 69 | timestamp: restaurantTimestamp, 70 | }; 71 | 72 | data.push({ 73 | restaurantData, 74 | ratingsData, 75 | }); 76 | } 77 | return data; 78 | } 79 | -------------------------------------------------------------------------------- /nextjs-end/src/lib/firebase/auth.js: -------------------------------------------------------------------------------- 1 | import { 2 | GoogleAuthProvider, 3 | signInWithPopup, 4 | onAuthStateChanged as _onAuthStateChanged, 5 | onIdTokenChanged as _onIdTokenChanged, 6 | } from "firebase/auth"; 7 | 8 | import { auth } from "@/src/lib/firebase/clientApp"; 9 | 10 | export function onAuthStateChanged(cb) { 11 | return _onAuthStateChanged(auth, cb); 12 | } 13 | 14 | export function onIdTokenChanged(cb) { 15 | return _onIdTokenChanged(auth, cb); 16 | } 17 | 18 | export async function signInWithGoogle() { 19 | const provider = new GoogleAuthProvider(); 20 | 21 | try { 22 | await signInWithPopup(auth, provider); 23 | } catch (error) { 24 | console.error("Error signing in with Google", error); 25 | } 26 | } 27 | 28 | export async function signOut() { 29 | try { 30 | return auth.signOut(); 31 | } catch (error) { 32 | console.error("Error signing out with Google", error); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /nextjs-end/src/lib/firebase/clientApp.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { initializeApp } from "firebase/app"; 4 | import { getAuth } from "firebase/auth"; 5 | import { getFirestore } from "firebase/firestore"; 6 | import { getStorage } from "firebase/storage"; 7 | 8 | // Use automatic initialization 9 | // https://firebase.google.com/docs/app-hosting/firebase-sdks#initialize-with-no-arguments 10 | export const firebaseApp = initializeApp(); 11 | 12 | export const auth = getAuth(firebaseApp); 13 | export const db = getFirestore(firebaseApp); 14 | export const storage = getStorage(firebaseApp); 15 | -------------------------------------------------------------------------------- /nextjs-end/src/lib/firebase/serverApp.js: -------------------------------------------------------------------------------- 1 | // enforces that this code can only be called on the server 2 | // https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment 3 | import "server-only"; 4 | 5 | import { cookies } from "next/headers"; 6 | import { initializeServerApp, initializeApp } from "firebase/app"; 7 | 8 | import { getAuth } from "firebase/auth"; 9 | 10 | // Returns an authenticated client SDK instance for use in Server Side Rendering 11 | // and Static Site Generation 12 | export async function getAuthenticatedAppForUser() { 13 | const authIdToken = (await cookies()).get("__session")?.value; 14 | 15 | // Firebase Server App is a new feature in the JS SDK that allows you to 16 | // instantiate the SDK with credentials retrieved from the client & has 17 | // other affordances for use in server environments. 18 | const firebaseServerApp = initializeServerApp( 19 | // https://github.com/firebase/firebase-js-sdk/issues/8863#issuecomment-2751401913 20 | initializeApp(), 21 | { 22 | authIdToken, 23 | } 24 | ); 25 | 26 | const auth = getAuth(firebaseServerApp); 27 | await auth.authStateReady(); 28 | 29 | return { firebaseServerApp, currentUser: auth.currentUser }; 30 | } 31 | -------------------------------------------------------------------------------- /nextjs-end/src/lib/firebase/storage.js: -------------------------------------------------------------------------------- 1 | import { ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; 2 | 3 | import { storage } from "@/src/lib/firebase/clientApp"; 4 | 5 | import { updateRestaurantImageReference } from "@/src/lib/firebase/firestore"; 6 | 7 | export async function updateRestaurantImage(restaurantId, image) { 8 | try { 9 | if (!restaurantId) { 10 | throw new Error("No restaurant ID has been provided."); 11 | } 12 | 13 | if (!image || !image.name) { 14 | throw new Error("A valid image has not been provided."); 15 | } 16 | 17 | const publicImageUrl = await uploadImage(restaurantId, image); 18 | await updateRestaurantImageReference(restaurantId, publicImageUrl); 19 | 20 | return publicImageUrl; 21 | } catch (error) { 22 | console.error("Error processing request:", error); 23 | } 24 | } 25 | 26 | async function uploadImage(restaurantId, image) { 27 | const filePath = `images/${restaurantId}/${image.name}`; 28 | const newImageRef = ref(storage, filePath); 29 | await uploadBytesResumable(newImageRef, image); 30 | 31 | return await getDownloadURL(newImageRef); 32 | } 33 | -------------------------------------------------------------------------------- /nextjs-end/src/lib/getUser.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { onAuthStateChanged } from "firebase/auth"; 4 | import { useEffect, useState } from "react"; 5 | 6 | import { auth } from "@/src/lib/firebase/clientApp.js"; 7 | 8 | export function useUser() { 9 | const [user, setUser] = useState(); 10 | 11 | useEffect(() => { 12 | return onAuthStateChanged(auth, (authUser) => { 13 | setUser(authUser); 14 | }); 15 | }, []); 16 | 17 | return user; 18 | } 19 | -------------------------------------------------------------------------------- /nextjs-end/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export function randomNumberBetween(min = 0, max = 1000) { 2 | return Math.floor(Math.random() * (max - min + 1) + min); 3 | } 4 | 5 | export function getRandomDateBefore(startingDate = new Date()) { 6 | const randomNumberOfDays = randomNumberBetween(20, 80); 7 | const randomDate = new Date( 8 | startingDate - randomNumberOfDays * 24 * 60 * 60 * 1000 9 | ); 10 | return randomDate; 11 | } 12 | 13 | export function getRandomDateAfter(startingDate = new Date()) { 14 | const randomNumberOfDays = randomNumberBetween(1, 19); 15 | const randomDate = new Date( 16 | startingDate.getTime() + randomNumberOfDays * 24 * 60 * 60 * 1000 17 | ); 18 | return randomDate; 19 | } 20 | -------------------------------------------------------------------------------- /nextjs-end/storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | 3 | // Craft rules based on data in your Firestore database 4 | // allow write: if firestore.get( 5 | // /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin; 6 | service firebase.storage { 7 | match /b/{bucket}/o { 8 | match /{allPaths=**} { 9 | allow read; 10 | allow write: if request.auth.uid != null; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /nextjs-start/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "next", "prettier"], 3 | "rules": { 4 | "@next/next/no-img-element": "off", 5 | "no-unused-vars": "off" 6 | } 7 | } -------------------------------------------------------------------------------- /nextjs-start/.gitignore: -------------------------------------------------------------------------------- 1 | lib/firebase/config.js 2 | .next/ 3 | .firebase/ 4 | node_modules/ 5 | .env -------------------------------------------------------------------------------- /nextjs-start/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5" 3 | } 4 | -------------------------------------------------------------------------------- /nextjs-start/apphosting.yaml: -------------------------------------------------------------------------------- 1 | runConfig: 2 | minInstances: 0 3 | maxInstances: 2 4 | -------------------------------------------------------------------------------- /nextjs-start/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "auth": { 4 | "port": 9099 5 | }, 6 | "functions": { 7 | "port": 5001 8 | }, 9 | "firestore": { 10 | "port": 8080 11 | }, 12 | "database": { 13 | "port": 9000 14 | }, 15 | "storage": { 16 | "port": 9199 17 | }, 18 | "ui": { 19 | "enabled": true 20 | }, 21 | "singleProjectMode": true, 22 | "hosting": { 23 | "port": 5000 24 | } 25 | }, 26 | "firestore": { 27 | "rules": "firestore.rules", 28 | "indexes": "firestore.indexes.json" 29 | }, 30 | "storage": { 31 | "rules": "storage.rules" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /nextjs-start/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "restaurants", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "category", 9 | "order": "ASCENDING" 10 | }, 11 | { 12 | "fieldPath": "avgRating", 13 | "order": "DESCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "restaurants", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "category", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "numRatings", 27 | "order": "DESCENDING" 28 | } 29 | ] 30 | }, 31 | { 32 | "collectionGroup": "restaurants", 33 | "queryScope": "COLLECTION", 34 | "fields": [ 35 | { 36 | "fieldPath": "category", 37 | "order": "ASCENDING" 38 | }, 39 | { 40 | "fieldPath": "price", 41 | "order": "ASCENDING" 42 | } 43 | ] 44 | }, 45 | { 46 | "collectionGroup": "restaurants", 47 | "queryScope": "COLLECTION", 48 | "fields": [ 49 | { 50 | "fieldPath": "city", 51 | "order": "ASCENDING" 52 | }, 53 | { 54 | "fieldPath": "avgRating", 55 | "order": "DESCENDING" 56 | } 57 | ] 58 | }, 59 | { 60 | "collectionGroup": "restaurants", 61 | "queryScope": "COLLECTION", 62 | "fields": [ 63 | { 64 | "fieldPath": "city", 65 | "order": "ASCENDING" 66 | }, 67 | { 68 | "fieldPath": "numRatings", 69 | "order": "DESCENDING" 70 | } 71 | ] 72 | }, 73 | { 74 | "collectionGroup": "restaurants", 75 | "queryScope": "COLLECTION", 76 | "fields": [ 77 | { 78 | "fieldPath": "city", 79 | "order": "ASCENDING" 80 | }, 81 | { 82 | "fieldPath": "price", 83 | "order": "ASCENDING" 84 | } 85 | ] 86 | }, 87 | { 88 | "collectionGroup": "restaurants", 89 | "queryScope": "COLLECTION", 90 | "fields": [ 91 | { 92 | "fieldPath": "price", 93 | "order": "ASCENDING" 94 | }, 95 | { 96 | "fieldPath": "avgRating", 97 | "order": "DESCENDING" 98 | } 99 | ] 100 | }, 101 | { 102 | "collectionGroup": "restaurants", 103 | "queryScope": "COLLECTION", 104 | "fields": [ 105 | { 106 | "fieldPath": "price", 107 | "order": "ASCENDING" 108 | }, 109 | { 110 | "fieldPath": "numRatings", 111 | "order": "DESCENDING" 112 | } 113 | ] 114 | } 115 | ], 116 | "fieldOverrides": [] 117 | } 118 | -------------------------------------------------------------------------------- /nextjs-start/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | 4 | // Determine if the value of the field "key" is the same 5 | // before and after the request. 6 | function unchanged(key) { 7 | return (key in resource.data) 8 | && (key in request.resource.data) 9 | && (resource.data[key] == request.resource.data[key]); 10 | } 11 | 12 | match /databases/{database}/documents { 13 | // Restaurants: 14 | // - Authenticated user can read 15 | // - Authenticated user can create/update (for demo purposes only) 16 | // - Updates are allowed if no fields are added and name is unchanged 17 | // - Deletes are not allowed (default) 18 | match /restaurants/{restaurantId} { 19 | allow read; 20 | allow create: if request.auth != null; 21 | allow update: if request.auth != null 22 | && unchanged("name"); 23 | 24 | // Ratings: 25 | // - Authenticated user can read 26 | // - Authenticated user can create if userId matches 27 | // - Deletes and updates are not allowed (default) 28 | match /ratings/{ratingId} { 29 | allow read; 30 | allow create: if request.auth != null; 31 | allow update: if request.auth != null 32 | && request.resource.data.userId == request.auth.uid; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /nextjs-start/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /nextjs-start/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | eslint: { 4 | ignoreDuringBuilds: true, 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /nextjs-start/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "npm run lint:next && npm:lint:prettier", 10 | "lint:next": "next lint", 11 | "lint:prettier": "prettier --check --ignore-path .gitignore .", 12 | "lint:fix": "npm run lint:next -- --fix && npm run lint:prettier -- --write" 13 | }, 14 | "dependencies": { 15 | "@genkit-ai/googleai": "^1.5.0", 16 | "cookies-next": "^5.1.0", 17 | "firebase": "^11.6.0", 18 | "genkit": "^1.5.0", 19 | "next": "15.1.6", 20 | "react": "19.0.0", 21 | "react-dom": "19.0.0", 22 | "server-only": "^0.0.1" 23 | }, 24 | "browser": { 25 | "fs": false, 26 | "os": false, 27 | "path": false, 28 | "child_process": false, 29 | "net": false, 30 | "tls": false 31 | }, 32 | "devDependencies": { 33 | "@next/eslint-plugin-next": "15.1.6", 34 | "eslint": "^8.57.1", 35 | "eslint-config-next": "15.1.6", 36 | "eslint-config-prettier": "^9.1.0", 37 | "prettier": "^3.5.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /nextjs-start/public/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /nextjs-start/public/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /nextjs-start/public/food.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /nextjs-start/public/location.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /nextjs-start/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nextjs-start/public/price.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /nextjs-start/public/profile.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nextjs-start/public/review.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | -------------------------------------------------------------------------------- /nextjs-start/public/sortBy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /nextjs-start/readme.md: -------------------------------------------------------------------------------- 1 | ### Friendly Eats with Next.js + Firebase 2 | 3 | The codelab has the full instructions, but as a quick start, you can do this. 4 | 5 | #### Run the application 6 | 7 | 1. In your terminal, run: 8 | 9 | ```sh 10 | firebase emulators:start --project demo-codelab-nextjs 11 | ``` 12 | 13 | 2. Copy the file `lib/firebase/config-copy.js` to `lib/firebase/config.js` and fill in the values from the Firebase console. 14 | 15 | 3. In a new terminal tab/window, run: 16 | 17 | ```sh 18 | npm i 19 | npm run dev 20 | ``` 21 | 22 | 4. In your browser, open the URL: `http://localhost:3000` 23 | 24 | #### Use the application 25 | 26 | 1. While on `http://localhost:3000/` within your browser, click the "Sign in" button in the top right corner and sign in. 27 | 2. In the dropdown menu in the top right menu, select "Add sample restaurants". 28 | -------------------------------------------------------------------------------- /nextjs-start/src/app/actions.js: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { addReviewToRestaurant } from "@/src/lib/firebase/firestore.js"; 4 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp.js"; 5 | import { getFirestore } from "firebase/firestore"; 6 | 7 | // This is a Server Action 8 | // https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions 9 | // Replace the function below 10 | export async function handleReviewFormSubmission(data) {} 11 | -------------------------------------------------------------------------------- /nextjs-start/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/nextjs-start/src/app/favicon.ico -------------------------------------------------------------------------------- /nextjs-start/src/app/layout.js: -------------------------------------------------------------------------------- 1 | import "@/src/app/styles.css"; 2 | import Header from "@/src/components/Header.jsx"; 3 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; 4 | // Force next.js to treat this route as server-side rendered 5 | // Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it 6 | export const dynamic = "force-dynamic"; 7 | 8 | export const metadata = { 9 | title: "FriendlyEats", 10 | description: 11 | "FriendlyEats is a restaurant review website built with Next.js and Firebase.", 12 | }; 13 | 14 | export default async function RootLayout({ children }) { 15 | const { currentUser } = await getAuthenticatedAppForUser(); 16 | return ( 17 | 18 | 19 |
    20 | 21 |
    {children}
    22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /nextjs-start/src/app/page.js: -------------------------------------------------------------------------------- 1 | import RestaurantListings from "@/src/components/RestaurantListings.jsx"; 2 | import { getRestaurants } from "@/src/lib/firebase/firestore.js"; 3 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp.js"; 4 | import { getFirestore } from "firebase/firestore"; 5 | 6 | // Force next.js to treat this route as server-side rendered 7 | // Without this line, during the build process, next.js will treat this route as static and build a static HTML file for it 8 | 9 | export const dynamic = "force-dynamic"; 10 | 11 | // This line also forces this route to be server-side rendered 12 | // export const revalidate = 0; 13 | 14 | export default async function Home(props) { 15 | const searchParams = await props.searchParams; 16 | // Using seachParams which Next.js provides, allows the filtering to happen on the server-side, for example: 17 | // ?city=London&category=Indian&sort=Review 18 | const { firebaseServerApp } = await getAuthenticatedAppForUser(); 19 | const restaurants = await getRestaurants( 20 | getFirestore(firebaseServerApp), 21 | searchParams 22 | ); 23 | return ( 24 |
    25 | 29 |
    30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /nextjs-start/src/app/restaurant/[id]/error.jsx: -------------------------------------------------------------------------------- 1 | "use client"; // Error components must be Client Components 2 | 3 | import { useEffect } from "react"; 4 | 5 | export default function Error({ error, reset }) { 6 | useEffect(() => { 7 | console.error(error); 8 | }, [error]); 9 | 10 | return ( 11 |
    12 |

    Something went wrong!

    13 | 21 |
    22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /nextjs-start/src/app/restaurant/[id]/page.jsx: -------------------------------------------------------------------------------- 1 | import Restaurant from "@/src/components/Restaurant.jsx"; 2 | import { Suspense } from "react"; 3 | import { getRestaurantById } from "@/src/lib/firebase/firestore.js"; 4 | import { 5 | getAuthenticatedAppForUser, 6 | getAuthenticatedAppForUser as getUser, 7 | } from "@/src/lib/firebase/serverApp.js"; 8 | import ReviewsList, { 9 | ReviewsListSkeleton, 10 | } from "@/src/components/Reviews/ReviewsList"; 11 | import { 12 | GeminiSummary, 13 | GeminiSummarySkeleton, 14 | } from "@/src/components/Reviews/ReviewSummary"; 15 | import { getFirestore } from "firebase/firestore"; 16 | 17 | export default async function Home(props) { 18 | // This is a server component, we can access URL 19 | // parameters via Next.js and download the data 20 | // we need for this page 21 | const params = await props.params; 22 | const { currentUser } = await getUser(); 23 | const { firebaseServerApp } = await getAuthenticatedAppForUser(); 24 | const restaurant = await getRestaurantById( 25 | getFirestore(firebaseServerApp), 26 | params.id 27 | ); 28 | 29 | return ( 30 |
    31 | 36 | }> 37 | 38 | 39 | 40 | } 42 | > 43 | 44 | 45 |
    46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Filters.jsx: -------------------------------------------------------------------------------- 1 | // The filters shown on the restaurant listings page 2 | 3 | import Tag from "@/src/components/Tag.jsx"; 4 | 5 | function FilterSelect({ label, options, value, onChange, name, icon }) { 6 | return ( 7 |
    8 | {label} 9 | 19 |
    20 | ); 21 | } 22 | 23 | export default function Filters({ filters, setFilters }) { 24 | const handleSelectionChange = (event, name) => { 25 | setFilters((prevFilters) => ({ 26 | ...prevFilters, 27 | [name]: event.target.value, 28 | })); 29 | }; 30 | 31 | const updateField = (type, value) => { 32 | setFilters({ ...filters, [type]: value }); 33 | }; 34 | 35 | return ( 36 |
    37 |
    38 | 39 | filter 40 |
    41 |

    Restaurants

    42 |

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

    43 |
    44 |
    45 | 46 |
    { 49 | event.preventDefault(); 50 | event.target.parentNode.removeAttribute("open"); 51 | }} 52 | > 53 | handleSelectionChange(event, "category")} 73 | name="category" 74 | icon="/food.svg" 75 | /> 76 | 77 | handleSelectionChange(event, "city")} 95 | name="city" 96 | icon="/location.svg" 97 | /> 98 | 99 | handleSelectionChange(event, "price")} 104 | name="price" 105 | icon="/price.svg" 106 | /> 107 | 108 | handleSelectionChange(event, "sort")} 113 | name="sort" 114 | icon="/sortBy.svg" 115 | /> 116 | 117 |
    118 | 119 | 133 | 136 | 137 |
    138 | 139 |
    140 | 141 |
    142 | {Object.entries(filters).map(([type, value]) => { 143 | // The main filter bar already specifies what 144 | // sorting is being used. So skip showing the 145 | // sorting as a 'tag' 146 | if (type == "sort" || value == "") { 147 | return null; 148 | } 149 | return ( 150 | 156 | ); 157 | })} 158 |
    159 |
    160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect } from "react"; 3 | import Link from "next/link"; 4 | import { 5 | signInWithGoogle, 6 | signOut, 7 | onIdTokenChanged, 8 | } from "@/src/lib/firebase/auth.js"; 9 | import { addFakeRestaurantsAndReviews } from "@/src/lib/firebase/firestore.js"; 10 | import { setCookie, deleteCookie } from "cookies-next"; 11 | 12 | function useUserSession(initialUser) { 13 | return; 14 | } 15 | 16 | export default function Header({ initialUser }) { 17 | const user = useUserSession(initialUser); 18 | 19 | const handleSignOut = (event) => { 20 | event.preventDefault(); 21 | signOut(); 22 | }; 23 | 24 | const handleSignIn = (event) => { 25 | event.preventDefault(); 26 | signInWithGoogle(); 27 | }; 28 | 29 | return ( 30 |
    31 | 32 | FriendlyEats 33 | Friendly Eats 34 | 35 | {user ? ( 36 | <> 37 |
    38 |

    39 | {user.email} 44 | {user.displayName} 45 |

    46 | 47 |
    48 | ... 49 | 64 |
    65 |
    66 | 67 | ) : ( 68 |
    69 | 70 | A placeholder user image 71 | Sign In with Google 72 | 73 |
    74 | )} 75 |
    76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /nextjs-start/src/components/RatingPicker.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // A HTML and CSS only rating picker thanks to: https://codepen.io/chris22smith/pen/MJzLJN 4 | 5 | const RatingPicker = () => { 6 | return ( 7 |

    8 | 15 | 18 | 19 | 26 | 29 | 30 | 37 | 40 | 41 | 48 | 51 | 52 | 59 | 62 |

    63 | ); 64 | }; 65 | 66 | export default RatingPicker; 67 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Restaurant.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // This components shows one individual restaurant 4 | // It receives data from src/app/restaurant/[id]/page.jsx 5 | 6 | import { React, useState, useEffect, Suspense } from "react"; 7 | import dynamic from "next/dynamic"; 8 | import { getRestaurantSnapshotById } from "@/src/lib/firebase/firestore.js"; 9 | import { useUser } from "@/src/lib/getUser"; 10 | import RestaurantDetails from "@/src/components/RestaurantDetails.jsx"; 11 | import { updateRestaurantImage } from "@/src/lib/firebase/storage.js"; 12 | 13 | const ReviewDialog = dynamic(() => import("@/src/components/ReviewDialog.jsx")); 14 | 15 | export default function Restaurant({ 16 | id, 17 | initialRestaurant, 18 | initialUserId, 19 | children, 20 | }) { 21 | const [restaurantDetails, setRestaurantDetails] = useState(initialRestaurant); 22 | const [isOpen, setIsOpen] = useState(false); 23 | 24 | // 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 25 | const userId = useUser()?.uid || initialUserId; 26 | const [review, setReview] = useState({ 27 | rating: 0, 28 | text: "", 29 | }); 30 | 31 | const onChange = (value, name) => { 32 | setReview({ ...review, [name]: value }); 33 | }; 34 | 35 | async function handleRestaurantImage(target) { 36 | const image = target.files ? target.files[0] : null; 37 | if (!image) { 38 | return; 39 | } 40 | 41 | const imageURL = await updateRestaurantImage(id, image); 42 | setRestaurantDetails({ ...restaurantDetails, photo: imageURL }); 43 | } 44 | 45 | const handleClose = () => { 46 | setIsOpen(false); 47 | setReview({ rating: 0, text: "" }); 48 | }; 49 | 50 | useEffect(() => { 51 | return getRestaurantSnapshotById(id, (data) => { 52 | setRestaurantDetails(data); 53 | }); 54 | }, [id]); 55 | 56 | return ( 57 | <> 58 | 65 | {children} 66 | 67 | {userId && ( 68 | Loading...

    }> 69 | 77 |
    78 | )} 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /nextjs-start/src/components/RestaurantDetails.jsx: -------------------------------------------------------------------------------- 1 | // This component shows restaurant metadata, and offers some actions to the user like uploading a new restaurant image, and adding a review. 2 | 3 | import React from "react"; 4 | import renderStars from "@/src/components/Stars.jsx"; 5 | 6 | const RestaurantDetails = ({ 7 | restaurant, 8 | userId, 9 | handleRestaurantImage, 10 | setIsOpen, 11 | isOpen, 12 | children, 13 | }) => { 14 | return ( 15 |
    16 | {restaurant.name} 17 | 18 |
    19 | {userId && ( 20 | review { 24 | setIsOpen(!isOpen); 25 | }} 26 | src="/review.svg" 27 | /> 28 | )} 29 | 43 |
    44 | 45 |
    46 |
    47 |

    {restaurant.name}

    48 | 49 |
    50 |
      {renderStars(restaurant.avgRating)}
    51 | 52 | ({restaurant.numRatings}) 53 |
    54 | 55 |

    56 | {restaurant.category} | {restaurant.city} 57 |

    58 |

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

    59 | {children} 60 |
    61 |
    62 |
    63 | ); 64 | }; 65 | 66 | export default RestaurantDetails; 67 | -------------------------------------------------------------------------------- /nextjs-start/src/components/RestaurantListings.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // This components handles the restaurant listings page 4 | // It receives data from src/app/page.jsx, such as the initial restaurants and search params from the URL 5 | 6 | import Link from "next/link"; 7 | import { React, useState, useEffect } from "react"; 8 | import { useRouter } from "next/navigation"; 9 | import renderStars from "@/src/components/Stars.jsx"; 10 | import { getRestaurantsSnapshot } from "@/src/lib/firebase/firestore.js"; 11 | import Filters from "@/src/components/Filters.jsx"; 12 | 13 | const RestaurantItem = ({ restaurant }) => ( 14 |
  • 15 | 16 | 17 | 18 |
  • 19 | ); 20 | 21 | const ActiveResturant = ({ restaurant }) => ( 22 |
    23 | 24 | 25 |
    26 | ); 27 | 28 | const ImageCover = ({ photo, name }) => ( 29 |
    30 | {name} 31 |
    32 | ); 33 | 34 | const ResturantDetails = ({ restaurant }) => ( 35 |
    36 |

    {restaurant.name}

    37 | 38 | 39 |
    40 | ); 41 | 42 | const RestaurantRating = ({ restaurant }) => ( 43 |
    44 |
      {renderStars(restaurant.avgRating)}
    45 | ({restaurant.numRatings}) 46 |
    47 | ); 48 | 49 | const RestaurantMetadata = ({ restaurant }) => ( 50 |
    51 |

    52 | {restaurant.category} | {restaurant.city} 53 |

    54 |

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

    55 |
    56 | ); 57 | 58 | export default function RestaurantListings({ 59 | initialRestaurants, 60 | searchParams, 61 | }) { 62 | const router = useRouter(); 63 | 64 | // The initial filters are the search params from the URL, useful for when the user refreshes the page 65 | const initialFilters = { 66 | city: searchParams.city || "", 67 | category: searchParams.category || "", 68 | price: searchParams.price || "", 69 | sort: searchParams.sort || "", 70 | }; 71 | 72 | const [restaurants, setRestaurants] = useState(initialRestaurants); 73 | const [filters, setFilters] = useState(initialFilters); 74 | 75 | useEffect(() => { 76 | routerWithFilters(router, filters); 77 | }, [router, filters]); 78 | 79 | useEffect(() => { 80 | return getRestaurantsSnapshot((data) => { 81 | setRestaurants(data); 82 | }, filters); 83 | }, [filters]); 84 | 85 | return ( 86 |
    87 | 88 |
      89 | {restaurants.map((restaurant) => ( 90 | 91 | ))} 92 |
    93 |
    94 | ); 95 | } 96 | 97 | function routerWithFilters(router, filters) { 98 | const queryParams = new URLSearchParams(); 99 | 100 | for (const [key, value] of Object.entries(filters)) { 101 | if (value !== undefined && value !== "") { 102 | queryParams.append(key, value); 103 | } 104 | } 105 | 106 | const queryString = queryParams.toString(); 107 | router.push(`?${queryString}`); 108 | } 109 | -------------------------------------------------------------------------------- /nextjs-start/src/components/ReviewDialog.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // This components handles the review dialog and uses a next.js feature known as Server Actions to handle the form submission 4 | 5 | import { useEffect, useLayoutEffect, useRef } from "react"; 6 | import RatingPicker from "@/src/components/RatingPicker.jsx"; 7 | import { handleReviewFormSubmission } from "@/src/app/actions.js"; 8 | 9 | const ReviewDialog = ({ 10 | isOpen, 11 | handleClose, 12 | review, 13 | onChange, 14 | userId, 15 | id, 16 | }) => { 17 | const dialog = useRef(); 18 | 19 | // dialogs only render their backdrop when called with `showModal` 20 | useLayoutEffect(() => { 21 | if (isOpen) { 22 | dialog.current.showModal(); 23 | } else { 24 | dialog.current.close(); 25 | } 26 | }, [isOpen, dialog]); 27 | 28 | const handleClick = (e) => { 29 | // close if clicked outside the modal 30 | if (e.target === dialog.current) { 31 | handleClose(); 32 | } 33 | }; 34 | 35 | return ( 36 | 37 |
    { 40 | handleClose(); 41 | }} 42 | > 43 |
    44 |

    Add your review

    45 |
    46 |
    47 | 48 | 49 |

    50 | onChange(e.target.value, "text")} 58 | /> 59 |

    60 | 61 | 62 | 63 |
    64 |
    65 | 66 | 74 | 77 | 78 |
    79 |
    80 |
    81 | ); 82 | }; 83 | 84 | export default ReviewDialog; 85 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Reviews/Review.jsx: -------------------------------------------------------------------------------- 1 | import renderStars from "@/src/components/Stars.jsx"; 2 | 3 | export function Review({ rating, text, timestamp }) { 4 | return ( 5 |
  • 6 |
      {renderStars(rating)}
    7 |

    {text}

    8 | 9 | 14 |
  • 15 | ); 16 | } 17 | 18 | export function ReviewSkeleton() { 19 | return ( 20 |
  • 21 |
    22 |
    29 |
    30 |
    37 |

    {" "}

    38 |
  • 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Reviews/ReviewSummary.jsx: -------------------------------------------------------------------------------- 1 | import { gemini20Flash, googleAI } from "@genkit-ai/googleai"; 2 | import { genkit } from "genkit"; 3 | import { getReviewsByRestaurantId } from "@/src/lib/firebase/firestore.js"; 4 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; 5 | import { getFirestore } from "firebase/firestore"; 6 | 7 | export async function GeminiSummary({ restaurantId }) { 8 | return ( 9 |
    10 |

    TODO: summarize reviews

    11 |
    12 | ); 13 | } 14 | 15 | export function GeminiSummarySkeleton() { 16 | return ( 17 |
    18 |

    ✨ Summarizing reviews with Gemini...

    19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Reviews/ReviewsList.jsx: -------------------------------------------------------------------------------- 1 | // This component handles the list of reviews for a given restaurant 2 | 3 | import React from "react"; 4 | import { getReviewsByRestaurantId } from "@/src/lib/firebase/firestore.js"; 5 | import ReviewsListClient from "@/src/components/Reviews/ReviewsListClient"; 6 | import { ReviewSkeleton } from "@/src/components/Reviews/Review"; 7 | import { getFirestore } from "firebase/firestore"; 8 | import { getAuthenticatedAppForUser } from "@/src/lib/firebase/serverApp"; 9 | 10 | export default async function ReviewsList({ restaurantId, userId }) { 11 | const { firebaseServerApp } = await getAuthenticatedAppForUser(); 12 | const reviews = await getReviewsByRestaurantId( 13 | getFirestore(firebaseServerApp), 14 | restaurantId 15 | ); 16 | 17 | return ( 18 | 23 | ); 24 | } 25 | 26 | export function ReviewsListSkeleton({ numReviews }) { 27 | return ( 28 |
    29 |
      30 |
        31 | {Array(numReviews) 32 | .fill(0) 33 | .map((value, index) => ( 34 | 35 | ))} 36 |
      37 |
    38 |
    39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Reviews/ReviewsListClient.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useEffect } from "react"; 4 | import { getReviewsSnapshotByRestaurantId } from "@/src/lib/firebase/firestore.js"; 5 | import { Review } from "@/src/components/Reviews/Review"; 6 | 7 | export default function ReviewsListClient({ 8 | initialReviews, 9 | restaurantId, 10 | userId, 11 | }) { 12 | const [reviews, setReviews] = useState(initialReviews); 13 | 14 | useEffect(() => { 15 | return getReviewsSnapshotByRestaurantId(restaurantId, (data) => { 16 | setReviews(data); 17 | }); 18 | }, [restaurantId]); 19 | return ( 20 |
    21 |
      22 | {reviews.length > 0 ? ( 23 |
        24 | {reviews.map((review) => ( 25 | 31 | ))} 32 |
      33 | ) : ( 34 |

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

      38 | )} 39 |
    40 |
    41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Stars.jsx: -------------------------------------------------------------------------------- 1 | // This component displays star ratings 2 | 3 | export default function renderStars(avgRating) { 4 | const arr = []; 5 | for (let i = 0; i < 5; i++) { 6 | if (i < Math.floor(avgRating)) { 7 | arr.push( 8 |
  • 9 | 15 | 20 | 21 |
  • 22 | ); 23 | } else { 24 | arr.push( 25 |
  • 26 | 34 | 39 | 40 |
  • 41 | ); 42 | } 43 | } 44 | return arr; 45 | } 46 | -------------------------------------------------------------------------------- /nextjs-start/src/components/Tag.jsx: -------------------------------------------------------------------------------- 1 | // A tag is shown under the filter bar when a filter is selected. 2 | // Tags show what filters have been selected 3 | // On click, the tag is removed and the filter is reset 4 | 5 | export default function Tag({ type, value, updateField }) { 6 | return ( 7 | 8 | {value} 9 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /nextjs-start/src/lib/fakeRestaurants.js: -------------------------------------------------------------------------------- 1 | import { 2 | randomNumberBetween, 3 | getRandomDateAfter, 4 | getRandomDateBefore, 5 | } from "@/src/lib/utils.js"; 6 | import { randomData } from "@/src/lib/randomData.js"; 7 | 8 | import { Timestamp } from "firebase/firestore"; 9 | 10 | export async function generateFakeRestaurantsAndReviews() { 11 | const restaurantsToAdd = 5; 12 | const data = []; 13 | 14 | for (let i = 0; i < restaurantsToAdd; i++) { 15 | const restaurantTimestamp = Timestamp.fromDate(getRandomDateBefore()); 16 | 17 | const ratingsData = []; 18 | 19 | // Generate a random number of ratings/reviews for this restaurant 20 | for (let j = 0; j < randomNumberBetween(0, 5); j++) { 21 | const ratingTimestamp = Timestamp.fromDate( 22 | getRandomDateAfter(restaurantTimestamp.toDate()) 23 | ); 24 | 25 | const ratingData = { 26 | rating: 27 | randomData.restaurantReviews[ 28 | randomNumberBetween(0, randomData.restaurantReviews.length - 1) 29 | ].rating, 30 | text: randomData.restaurantReviews[ 31 | randomNumberBetween(0, randomData.restaurantReviews.length - 1) 32 | ].text, 33 | userId: `User #${randomNumberBetween()}`, 34 | timestamp: ratingTimestamp, 35 | }; 36 | 37 | ratingsData.push(ratingData); 38 | } 39 | 40 | const avgRating = ratingsData.length 41 | ? ratingsData.reduce( 42 | (accumulator, currentValue) => accumulator + currentValue.rating, 43 | 0 44 | ) / ratingsData.length 45 | : 0; 46 | 47 | const restaurantData = { 48 | category: 49 | randomData.restaurantCategories[ 50 | randomNumberBetween(0, randomData.restaurantCategories.length - 1) 51 | ], 52 | name: randomData.restaurantNames[ 53 | randomNumberBetween(0, randomData.restaurantNames.length - 1) 54 | ], 55 | avgRating, 56 | city: randomData.restaurantCities[ 57 | randomNumberBetween(0, randomData.restaurantCities.length - 1) 58 | ], 59 | numRatings: ratingsData.length, 60 | sumRating: ratingsData.reduce( 61 | (accumulator, currentValue) => accumulator + currentValue.rating, 62 | 0 63 | ), 64 | price: randomNumberBetween(1, 4), 65 | photo: `https://storage.googleapis.com/firestorequickstarts.appspot.com/food_${randomNumberBetween( 66 | 1, 67 | 22 68 | )}.png`, 69 | timestamp: restaurantTimestamp, 70 | }; 71 | 72 | data.push({ 73 | restaurantData, 74 | ratingsData, 75 | }); 76 | } 77 | return data; 78 | } 79 | -------------------------------------------------------------------------------- /nextjs-start/src/lib/firebase/auth.js: -------------------------------------------------------------------------------- 1 | import { 2 | GoogleAuthProvider, 3 | signInWithPopup, 4 | onAuthStateChanged as _onAuthStateChanged, 5 | onIdTokenChanged as _onIdTokenChanged, 6 | } from "firebase/auth"; 7 | 8 | import { auth } from "@/src/lib/firebase/clientApp"; 9 | 10 | export function onAuthStateChanged(cb) { 11 | return () => {}; 12 | } 13 | 14 | export function onIdTokenChanged(cb) { 15 | return () => {}; 16 | } 17 | 18 | export async function signInWithGoogle() { 19 | return; 20 | } 21 | 22 | export async function signOut() { 23 | return; 24 | } 25 | -------------------------------------------------------------------------------- /nextjs-start/src/lib/firebase/clientApp.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { initializeApp } from "firebase/app"; 4 | import { getAuth } from "firebase/auth"; 5 | import { getFirestore } from "firebase/firestore"; 6 | import { getStorage } from "firebase/storage"; 7 | 8 | // Use automatic initialization 9 | // https://firebase.google.com/docs/app-hosting/firebase-sdks#initialize-with-no-arguments 10 | export const firebaseApp = initializeApp(); 11 | 12 | export const auth = getAuth(firebaseApp); 13 | export const db = getFirestore(firebaseApp); 14 | export const storage = getStorage(firebaseApp); 15 | -------------------------------------------------------------------------------- /nextjs-start/src/lib/firebase/firestore.js: -------------------------------------------------------------------------------- 1 | import { generateFakeRestaurantsAndReviews } from "@/src/lib/fakeRestaurants.js"; 2 | 3 | import { 4 | collection, 5 | onSnapshot, 6 | query, 7 | getDocs, 8 | doc, 9 | getDoc, 10 | updateDoc, 11 | orderBy, 12 | Timestamp, 13 | runTransaction, 14 | where, 15 | addDoc, 16 | getFirestore, 17 | } from "firebase/firestore"; 18 | 19 | import { db } from "@/src/lib/firebase/clientApp"; 20 | 21 | export async function updateRestaurantImageReference( 22 | restaurantId, 23 | publicImageUrl 24 | ) { 25 | const restaurantRef = doc(collection(db, "restaurants"), restaurantId); 26 | if (restaurantRef) { 27 | await updateDoc(restaurantRef, { photo: publicImageUrl }); 28 | } 29 | } 30 | 31 | const updateWithRating = async ( 32 | transaction, 33 | docRef, 34 | newRatingDocument, 35 | review 36 | ) => { 37 | return; 38 | }; 39 | 40 | export async function addReviewToRestaurant(db, restaurantId, review) { 41 | return; 42 | } 43 | 44 | function applyQueryFilters(q, { category, city, price, sort }) { 45 | return; 46 | } 47 | 48 | export async function getRestaurants(db = db, filters = {}) { 49 | return []; 50 | } 51 | 52 | export function getRestaurantsSnapshot(cb, filters = {}) { 53 | return; 54 | } 55 | 56 | export async function getRestaurantById(db, restaurantId) { 57 | if (!restaurantId) { 58 | console.log("Error: Invalid ID received: ", restaurantId); 59 | return; 60 | } 61 | const docRef = doc(db, "restaurants", restaurantId); 62 | const docSnap = await getDoc(docRef); 63 | return { 64 | ...docSnap.data(), 65 | timestamp: docSnap.data().timestamp.toDate(), 66 | }; 67 | } 68 | 69 | export function getRestaurantSnapshotById(restaurantId, cb) { 70 | return; 71 | } 72 | 73 | export async function getReviewsByRestaurantId(db, restaurantId) { 74 | if (!restaurantId) { 75 | console.log("Error: Invalid restaurantId received: ", restaurantId); 76 | return; 77 | } 78 | 79 | const q = query( 80 | collection(db, "restaurants", restaurantId, "ratings"), 81 | orderBy("timestamp", "desc") 82 | ); 83 | 84 | const results = await getDocs(q); 85 | return results.docs.map((doc) => { 86 | return { 87 | id: doc.id, 88 | ...doc.data(), 89 | // Only plain objects can be passed to Client Components from Server Components 90 | timestamp: doc.data().timestamp.toDate(), 91 | }; 92 | }); 93 | } 94 | 95 | export function getReviewsSnapshotByRestaurantId(restaurantId, cb) { 96 | if (!restaurantId) { 97 | console.log("Error: Invalid restaurantId received: ", restaurantId); 98 | return; 99 | } 100 | 101 | const q = query( 102 | collection(db, "restaurants", restaurantId, "ratings"), 103 | orderBy("timestamp", "desc") 104 | ); 105 | return onSnapshot(q, (querySnapshot) => { 106 | const results = querySnapshot.docs.map((doc) => { 107 | return { 108 | id: doc.id, 109 | ...doc.data(), 110 | // Only plain objects can be passed to Client Components from Server Components 111 | timestamp: doc.data().timestamp.toDate(), 112 | }; 113 | }); 114 | cb(results); 115 | }); 116 | } 117 | 118 | export async function addFakeRestaurantsAndReviews() { 119 | const data = await generateFakeRestaurantsAndReviews(); 120 | for (const { restaurantData, ratingsData } of data) { 121 | try { 122 | const docRef = await addDoc( 123 | collection(db, "restaurants"), 124 | restaurantData 125 | ); 126 | 127 | for (const ratingData of ratingsData) { 128 | await addDoc( 129 | collection(db, "restaurants", docRef.id, "ratings"), 130 | ratingData 131 | ); 132 | } 133 | } catch (e) { 134 | console.log("There was an error adding the document"); 135 | console.error("Error adding document: ", e); 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /nextjs-start/src/lib/firebase/serverApp.js: -------------------------------------------------------------------------------- 1 | // enforces that this code can only be called on the server 2 | // https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment 3 | import "server-only"; 4 | 5 | import { cookies } from "next/headers"; 6 | import { initializeServerApp, initializeApp } from "firebase/app"; 7 | 8 | import { getAuth } from "firebase/auth"; 9 | 10 | // Returns an authenticated client SDK instance for use in Server Side Rendering 11 | // and Static Site Generation 12 | export async function getAuthenticatedAppForUser() { 13 | throw new Error("not implemented"); 14 | } 15 | -------------------------------------------------------------------------------- /nextjs-start/src/lib/firebase/storage.js: -------------------------------------------------------------------------------- 1 | import { ref, uploadBytesResumable, getDownloadURL } from "firebase/storage"; 2 | 3 | import { storage } from "@/src/lib/firebase/clientApp"; 4 | 5 | import { updateRestaurantImageReference } from "@/src/lib/firebase/firestore"; 6 | 7 | // Replace the two functions below 8 | export async function updateRestaurantImage(restaurantId, image) {} 9 | 10 | async function uploadImage(restaurantId, image) {} 11 | // Replace the two functions above 12 | -------------------------------------------------------------------------------- /nextjs-start/src/lib/getUser.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { onAuthStateChanged } from "firebase/auth"; 4 | import { useEffect, useState } from "react"; 5 | 6 | import { auth } from "@/src/lib/firebase/clientApp.js"; 7 | import { useRouter } from "next/navigation"; 8 | 9 | export function useUser() { 10 | const [user, setUser] = useState(); 11 | 12 | useEffect(() => { 13 | return onAuthStateChanged(auth, (authUser) => { 14 | setUser(authUser); 15 | }); 16 | }, []); 17 | 18 | return user; 19 | } 20 | -------------------------------------------------------------------------------- /nextjs-start/src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export function randomNumberBetween(min = 0, max = 1000) { 2 | return Math.floor(Math.random() * (max - min + 1) + min); 3 | } 4 | 5 | export function getRandomDateBefore(startingDate = new Date()) { 6 | const randomNumberOfDays = randomNumberBetween(20, 80); 7 | const randomDate = new Date( 8 | startingDate - randomNumberOfDays * 24 * 60 * 60 * 1000 9 | ); 10 | return randomDate; 11 | } 12 | 13 | export function getRandomDateAfter(startingDate = new Date()) { 14 | const randomNumberOfDays = randomNumberBetween(1, 19); 15 | const randomDate = new Date( 16 | startingDate.getTime() + randomNumberOfDays * 24 * 60 * 60 * 1000 17 | ); 18 | return randomDate; 19 | } 20 | -------------------------------------------------------------------------------- /nextjs-start/storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | 3 | // Craft rules based on data in your Firestore database 4 | // allow write: if firestore.get( 5 | // /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin; 6 | service firebase.storage { 7 | match /b/{bucket}/o { 8 | match /{allPaths=**} { 9 | allow read; 10 | allow write: if request.auth.uid != null; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /reactfire-end/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "functions": [ 7 | { 8 | "source": "functions", 9 | "codebase": "default", 10 | "ignore": [ 11 | "node_modules", 12 | ".git", 13 | "firebase-debug.log", 14 | "firebase-debug.*.log" 15 | ], 16 | "predeploy": [ 17 | "npm --prefix \"$RESOURCE_DIR\" run lint" 18 | ] 19 | } 20 | ], 21 | "hosting": { 22 | "source": "hosting", 23 | "ignore": [ 24 | "firebase.json", 25 | "**/.*", 26 | "**/node_modules/**" 27 | ] 28 | }, 29 | "storage": { 30 | "rules": "storage.rules" 31 | }, 32 | "emulators": { 33 | "auth": { 34 | "port": 9099 35 | }, 36 | "functions": { 37 | "port": 5001 38 | }, 39 | "firestore": { 40 | "port": 8080 41 | }, 42 | "hosting": { 43 | "port": 5000 44 | }, 45 | "storage": { 46 | "port": 9199 47 | }, 48 | "ui": { 49 | "enabled": true 50 | }, 51 | "singleProjectMode": true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /reactfire-end/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "restaurants", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "category", 9 | "order": "ASCENDING" 10 | }, 11 | { 12 | "fieldPath": "avgRating", 13 | "order": "DESCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "restaurants", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "category", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "numRatings", 27 | "order": "DESCENDING" 28 | } 29 | ] 30 | }, 31 | { 32 | "collectionGroup": "restaurants", 33 | "queryScope": "COLLECTION", 34 | "fields": [ 35 | { 36 | "fieldPath": "category", 37 | "order": "ASCENDING" 38 | }, 39 | { 40 | "fieldPath": "price", 41 | "order": "ASCENDING" 42 | } 43 | ] 44 | }, 45 | { 46 | "collectionGroup": "restaurants", 47 | "queryScope": "COLLECTION", 48 | "fields": [ 49 | { 50 | "fieldPath": "city", 51 | "order": "ASCENDING" 52 | }, 53 | { 54 | "fieldPath": "avgRating", 55 | "order": "DESCENDING" 56 | } 57 | ] 58 | }, 59 | { 60 | "collectionGroup": "restaurants", 61 | "queryScope": "COLLECTION", 62 | "fields": [ 63 | { 64 | "fieldPath": "city", 65 | "order": "ASCENDING" 66 | }, 67 | { 68 | "fieldPath": "numRatings", 69 | "order": "DESCENDING" 70 | } 71 | ] 72 | }, 73 | { 74 | "collectionGroup": "restaurants", 75 | "queryScope": "COLLECTION", 76 | "fields": [ 77 | { 78 | "fieldPath": "city", 79 | "order": "ASCENDING" 80 | }, 81 | { 82 | "fieldPath": "price", 83 | "order": "ASCENDING" 84 | } 85 | ] 86 | }, 87 | { 88 | "collectionGroup": "restaurants", 89 | "queryScope": "COLLECTION", 90 | "fields": [ 91 | { 92 | "fieldPath": "price", 93 | "order": "ASCENDING" 94 | }, 95 | { 96 | "fieldPath": "avgRating", 97 | "order": "DESCENDING" 98 | } 99 | ] 100 | }, 101 | { 102 | "collectionGroup": "restaurants", 103 | "queryScope": "COLLECTION", 104 | "fields": [ 105 | { 106 | "fieldPath": "price", 107 | "order": "ASCENDING" 108 | }, 109 | { 110 | "fieldPath": "numRatings", 111 | "order": "DESCENDING" 112 | } 113 | ] 114 | } 115 | ], 116 | "fieldOverrides": [] 117 | } 118 | -------------------------------------------------------------------------------- /reactfire-end/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | 4 | // Determine if the value of the field "key" is the same 5 | // before and after the request. 6 | function unchanged(key) { 7 | return (key in resource.data) 8 | && (key in request.resource.data) 9 | && (resource.data[key] == request.resource.data[key]); 10 | } 11 | 12 | match /databases/{database}/documents { 13 | // Restaurants: 14 | // - Authenticated user can read 15 | // - Authenticated user can create/update (for demo purposes only) 16 | // - Updates are allowed if no fields are added and name is unchanged 17 | // - Deletes are not allowed (default) 18 | match /restaurants/{restaurantId} { 19 | allow read; 20 | allow create: if request.auth != null; 21 | allow update: if request.auth != null 22 | && unchanged("name"); 23 | 24 | // Ratings: 25 | // - Authenticated user can read 26 | // - Authenticated user can create if userId matches 27 | // - Deletes and updates are not allowed (default) 28 | match /ratings/{ratingId} { 29 | allow read; 30 | allow create: if request.auth != null; 31 | allow update: if request.auth != null 32 | && request.resource.data.userId == request.auth.uid; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /reactfire-end/functions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | }, 6 | parserOptions: { 7 | "ecmaVersion": 2018, 8 | }, 9 | extends: [ 10 | "eslint:recommended", 11 | "google", 12 | ], 13 | rules: { 14 | "no-restricted-globals": ["error", "name", "length"], 15 | "prefer-arrow-callback": "error", 16 | "quotes": ["error", "double", {"allowTemplateLiterals": true}], 17 | }, 18 | overrides: [ 19 | { 20 | files: ["**/*.spec.*"], 21 | env: { 22 | mocha: true, 23 | }, 24 | rules: {}, 25 | }, 26 | ], 27 | globals: {}, 28 | }; 29 | -------------------------------------------------------------------------------- /reactfire-end/functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /reactfire-end/functions/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Import function triggers from their respective submodules: 3 | * 4 | * const {onCall} = require("firebase-functions/v2/https"); 5 | * const {onDocumentWritten} = require("firebase-functions/v2/firestore"); 6 | * 7 | * See a full list of supported triggers at https://firebase.google.com/docs/functions 8 | */ 9 | 10 | // const {onRequest} = require("firebase-functions/v2/https"); 11 | // const logger = require("firebase-functions/logger"); 12 | 13 | // Create and deploy your first functions 14 | // https://firebase.google.com/docs/functions/get-started 15 | 16 | // exports.helloWorld = onRequest((request, response) => { 17 | // logger.info("Hello logs!", {structuredData: true}); 18 | // response.send("Hello from Firebase!"); 19 | // }); 20 | -------------------------------------------------------------------------------- /reactfire-end/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "scripts": { 5 | "lint": "eslint .", 6 | "serve": "firebase emulators:start --only functions", 7 | "shell": "firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "engines": { 13 | "node": "18" 14 | }, 15 | "main": "index.js", 16 | "dependencies": { 17 | "firebase-admin": "^11.8.0", 18 | "firebase-functions": "^4.3.1" 19 | }, 20 | "devDependencies": { 21 | "eslint": "^8.15.0", 22 | "eslint-config-google": "^0.14.0", 23 | "firebase-functions-test": "^3.1.0" 24 | }, 25 | "private": true 26 | } 27 | -------------------------------------------------------------------------------- /reactfire-end/hosting/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /reactfire-end/hosting/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /reactfire-end/hosting/images/FriendlyEats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/reactfire-end/hosting/images/FriendlyEats.png -------------------------------------------------------------------------------- /reactfire-end/hosting/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/reactfire-end/hosting/images/favicon.ico -------------------------------------------------------------------------------- /reactfire-end/hosting/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | FriendlyEats 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 |
    38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /reactfire-end/hosting/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hosting", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0" 15 | }, 16 | "devDependencies": { 17 | "@types/react": "^18.0.37", 18 | "@types/react-dom": "^18.0.11", 19 | "@typescript-eslint/eslint-plugin": "^5.59.0", 20 | "@typescript-eslint/parser": "^5.59.0", 21 | "@vitejs/plugin-react": "^4.0.0", 22 | "eslint": "^8.38.0", 23 | "eslint-plugin-react-hooks": "^4.6.0", 24 | "eslint-plugin-react-refresh": "^0.3.4", 25 | "typescript": "^5.0.2", 26 | "vite": "^4.3.9" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth, connectAuthEmulator } from 'firebase/auth'; 2 | import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore'; 3 | import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; 4 | import { getStorage, connectStorageEmulator } from 'firebase/storage'; 5 | import { Routes, Route } from 'react-router-dom'; 6 | import { 7 | useFirebaseApp, 8 | FirestoreProvider, 9 | StorageProvider, 10 | AuthProvider, 11 | FunctionsProvider, 12 | } from 'reactfire'; 13 | import Header from './components/header'; 14 | import Home from './pages/home'; 15 | import Restaurant from './pages/restaurant'; 16 | 17 | function App() { 18 | const app = useFirebaseApp(); 19 | const firestoreInstance = getFirestore(app); 20 | const storageInstance = getStorage(app); 21 | const authInstance = getAuth(app); 22 | const functionsInstance = getFunctions(app); 23 | if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') { 24 | // Set up emulators 25 | connectStorageEmulator(storageInstance, '127.0.0.1', 9199); 26 | connectAuthEmulator(authInstance, 'http://127.0.0.1:9099', { 27 | disableWarnings: true, 28 | }); 29 | connectFirestoreEmulator(firestoreInstance, '127.0.0.1', 8080); 30 | connectFunctionsEmulator(functionsInstance, '127.0.0.1', 5001); 31 | } 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | }> 39 | } /> 40 | } /> 41 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | export default App; 51 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/arrowDown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/food.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/location.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/price.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/review.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/assets/sortBy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/firebase-config.tsx: -------------------------------------------------------------------------------- 1 | export const firebaseConfig = { 2 | // Your Firebase configs 3 | apiKey: 'API_KEY', 4 | authDomain: 'PROJECT_ID.firebaseapp.com', 5 | databaseURL: 'https://PROJECT_ID.firebaseio.com', 6 | projectId: 'PROJECT_ID', 7 | storageBucket: 'PROJECT_ID.appspot.com', 8 | messagingSenderId: 'SENDER_ID', 9 | appId: 'APP_ID', 10 | measurementId: 'G-MEASUREMENT_ID', 11 | }; 12 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .dropdown:hover .dropdown-menu { 6 | display: block; 7 | } 8 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './app.tsx'; 4 | import './index.css'; 5 | import { FirebaseAppProvider } from 'reactfire'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | import { firebaseConfig } from './firebase-config.tsx'; 8 | 9 | const root = createRoot(document.getElementById('root') as HTMLElement); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /reactfire-end/hosting/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /reactfire-end/hosting/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | "paths": { 17 | "react": [ "./node_modules/@types/react" ] 18 | }, 19 | "allowSyntheticDefaultImports" : true, 20 | 21 | /* Linting */ 22 | "strict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noFallthroughCasesInSwitch": true 26 | }, 27 | "include": ["src"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /reactfire-end/hosting/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /reactfire-end/hosting/vite.config.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: import('vite').UserConfigExport; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /reactfire-end/hosting/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | // https://vitejs.dev/config/ 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }); 7 | -------------------------------------------------------------------------------- /reactfire-end/hosting/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /reactfire-end/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@headlessui/react": "^1.7.14", 4 | "@react-rxjs/core": "^0.10.4", 5 | "@react-rxjs/utils": "^0.9.5", 6 | "firebase": "^9.22.1", 7 | "react-router-dom": "^6.11.2", 8 | "reactfire": "^4.2.2", 9 | "rxjs": "^7.8.1" 10 | }, 11 | "devDependencies": { 12 | "tailwindcss": "^3.3.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /reactfire-end/storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | 3 | // Craft rules based on data in your Firestore database 4 | // allow write: if firestore.get( 5 | // /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin; 6 | service firebase.storage { 7 | match /b/{bucket}/o { 8 | match /{allPaths=**} { 9 | allow read, write: if false; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reactfire-end/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./hosting/src/**/*.{html,js,tsx,ts}", "./hosting/src/***/**/*.{html,js,tsx,ts}", "./hosting/src/*.{html,js,tsx,ts}", "./hosting/node_modules/tw-elements/dist/js/**/*.js",], 4 | theme: { 5 | extend: { 6 | colors: { 7 | 'navy': { 8 | 10: '#F6F7F9', 9 | 20: '#E5EAF0', 10 | 30: '#D4DCE7', 11 | 40: '#C3CFDD', 12 | 50: '#B2C1D4', 13 | 100: '#8EA1B9', 14 | 200: '#6B829D', 15 | 300: '#476282', 16 | 400: '#385574', 17 | 500: '#2A4865', 18 | 600: '#1B3A57', 19 | 700: '#0C2D48', 20 | 800: '#051E34', 21 | 900: '#031525', 22 | }, 23 | 'amber': { 24 | 50: '#FFF8E1', 25 | 100: '#FFECB3', 26 | 200: '#FFE082', 27 | 300: '#FFD54F', 28 | 400: '#FFCA28', 29 | 500: '#FFC107', 30 | 600: '#FFB300', 31 | 700: '#FFA000', 32 | 800: '#FF8F00', 33 | 900: '#FF6F00', 34 | A100: '#FFE57F', 35 | A200: '#FFD740', 36 | A400: '#FFC400', 37 | A700: '#FFAB00', 38 | } 39 | }, 40 | }, 41 | 42 | }, 43 | plugins: [], 44 | } 45 | 46 | -------------------------------------------------------------------------------- /reactfire-start/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "functions": [ 7 | { 8 | "source": "functions", 9 | "codebase": "default", 10 | "ignore": [ 11 | "node_modules", 12 | ".git", 13 | "firebase-debug.log", 14 | "firebase-debug.*.log" 15 | ], 16 | "predeploy": [ 17 | "npm --prefix \"$RESOURCE_DIR\" run lint" 18 | ] 19 | } 20 | ], 21 | "hosting": { 22 | "source": "hosting", 23 | "ignore": [ 24 | "firebase.json", 25 | "**/.*", 26 | "**/node_modules/**" 27 | ] 28 | }, 29 | "storage": { 30 | "rules": "storage.rules" 31 | }, 32 | "emulators": { 33 | "auth": { 34 | "port": 9099 35 | }, 36 | "functions": { 37 | "port": 5001 38 | }, 39 | "firestore": { 40 | "port": 8080 41 | }, 42 | "hosting": { 43 | "port": 5000 44 | }, 45 | "storage": { 46 | "port": 9199 47 | }, 48 | "ui": { 49 | "enabled": true 50 | }, 51 | "singleProjectMode": true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /reactfire-start/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "restaurants", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "category", 9 | "order": "ASCENDING" 10 | }, 11 | { 12 | "fieldPath": "avgRating", 13 | "order": "DESCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "restaurants", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "category", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "numRatings", 27 | "order": "DESCENDING" 28 | } 29 | ] 30 | }, 31 | { 32 | "collectionGroup": "restaurants", 33 | "queryScope": "COLLECTION", 34 | "fields": [ 35 | { 36 | "fieldPath": "category", 37 | "order": "ASCENDING" 38 | }, 39 | { 40 | "fieldPath": "price", 41 | "order": "ASCENDING" 42 | } 43 | ] 44 | }, 45 | { 46 | "collectionGroup": "restaurants", 47 | "queryScope": "COLLECTION", 48 | "fields": [ 49 | { 50 | "fieldPath": "city", 51 | "order": "ASCENDING" 52 | }, 53 | { 54 | "fieldPath": "avgRating", 55 | "order": "DESCENDING" 56 | } 57 | ] 58 | }, 59 | { 60 | "collectionGroup": "restaurants", 61 | "queryScope": "COLLECTION", 62 | "fields": [ 63 | { 64 | "fieldPath": "city", 65 | "order": "ASCENDING" 66 | }, 67 | { 68 | "fieldPath": "numRatings", 69 | "order": "DESCENDING" 70 | } 71 | ] 72 | }, 73 | { 74 | "collectionGroup": "restaurants", 75 | "queryScope": "COLLECTION", 76 | "fields": [ 77 | { 78 | "fieldPath": "city", 79 | "order": "ASCENDING" 80 | }, 81 | { 82 | "fieldPath": "price", 83 | "order": "ASCENDING" 84 | } 85 | ] 86 | }, 87 | { 88 | "collectionGroup": "restaurants", 89 | "queryScope": "COLLECTION", 90 | "fields": [ 91 | { 92 | "fieldPath": "price", 93 | "order": "ASCENDING" 94 | }, 95 | { 96 | "fieldPath": "avgRating", 97 | "order": "DESCENDING" 98 | } 99 | ] 100 | }, 101 | { 102 | "collectionGroup": "restaurants", 103 | "queryScope": "COLLECTION", 104 | "fields": [ 105 | { 106 | "fieldPath": "price", 107 | "order": "ASCENDING" 108 | }, 109 | { 110 | "fieldPath": "numRatings", 111 | "order": "DESCENDING" 112 | } 113 | ] 114 | } 115 | ], 116 | "fieldOverrides": [] 117 | } 118 | -------------------------------------------------------------------------------- /reactfire-start/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | 4 | // Determine if the value of the field "key" is the same 5 | // before and after the request. 6 | function unchanged(key) { 7 | return (key in resource.data) 8 | && (key in request.resource.data) 9 | && (resource.data[key] == request.resource.data[key]); 10 | } 11 | 12 | match /databases/{database}/documents { 13 | // Restaurants: 14 | // - Authenticated user can read 15 | // - Authenticated user can create/update (for demo purposes only) 16 | // - Updates are allowed if no fields are added and name is unchanged 17 | // - Deletes are not allowed (default) 18 | match /restaurants/{restaurantId} { 19 | allow read; 20 | allow create: if request.auth != null; 21 | allow update: if request.auth != null 22 | && unchanged("name"); 23 | 24 | // Ratings: 25 | // - Authenticated user can read 26 | // - Authenticated user can create if userId matches 27 | // - Deletes and updates are not allowed (default) 28 | match /ratings/{ratingId} { 29 | allow read; 30 | allow create: if request.auth != null; 31 | allow update: if request.auth != null 32 | && request.resource.data.userId == request.auth.uid; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /reactfire-start/functions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | }, 6 | parserOptions: { 7 | "ecmaVersion": 2018, 8 | }, 9 | extends: [ 10 | "eslint:recommended", 11 | "google", 12 | ], 13 | rules: { 14 | "no-restricted-globals": ["error", "name", "length"], 15 | "prefer-arrow-callback": "error", 16 | "quotes": ["error", "double", {"allowTemplateLiterals": true}], 17 | }, 18 | overrides: [ 19 | { 20 | files: ["**/*.spec.*"], 21 | env: { 22 | mocha: true, 23 | }, 24 | rules: {}, 25 | }, 26 | ], 27 | globals: {}, 28 | }; 29 | -------------------------------------------------------------------------------- /reactfire-start/functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /reactfire-start/functions/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Import function triggers from their respective submodules: 3 | * 4 | * const {onCall} = require("firebase-functions/v2/https"); 5 | * const {onDocumentWritten} = require("firebase-functions/v2/firestore"); 6 | * 7 | * See a full list of supported triggers at https://firebase.google.com/docs/functions 8 | */ 9 | 10 | // const {onRequest} = require("firebase-functions/v2/https"); 11 | // const logger = require("firebase-functions/logger"); 12 | 13 | // Create and deploy your first functions 14 | // https://firebase.google.com/docs/functions/get-started 15 | 16 | // exports.helloWorld = onRequest((request, response) => { 17 | // logger.info("Hello logs!", {structuredData: true}); 18 | // response.send("Hello from Firebase!"); 19 | // }); 20 | -------------------------------------------------------------------------------- /reactfire-start/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "scripts": { 5 | "lint": "eslint .", 6 | "serve": "firebase emulators:start --only functions", 7 | "shell": "firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "engines": { 13 | "node": "18" 14 | }, 15 | "main": "index.js", 16 | "dependencies": { 17 | "firebase-admin": "^11.8.0", 18 | "firebase-functions": "^4.3.1" 19 | }, 20 | "devDependencies": { 21 | "eslint": "^8.15.0", 22 | "eslint-config-google": "^0.14.0", 23 | "firebase-functions-test": "^3.1.0" 24 | }, 25 | "private": true 26 | } 27 | -------------------------------------------------------------------------------- /reactfire-start/hosting/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /reactfire-start/hosting/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /reactfire-start/hosting/images/FriendlyEats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/reactfire-start/hosting/images/FriendlyEats.png -------------------------------------------------------------------------------- /reactfire-start/hosting/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/reactfire-start/hosting/images/favicon.ico -------------------------------------------------------------------------------- /reactfire-start/hosting/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | FriendlyEats 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 |
    38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /reactfire-start/hosting/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hosting", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0" 15 | }, 16 | "devDependencies": { 17 | "@types/react": "^18.0.37", 18 | "@types/react-dom": "^18.0.11", 19 | "@typescript-eslint/eslint-plugin": "^5.59.0", 20 | "@typescript-eslint/parser": "^5.59.0", 21 | "@vitejs/plugin-react": "^4.0.0", 22 | "eslint": "^8.38.0", 23 | "eslint-plugin-react-hooks": "^4.6.0", 24 | "eslint-plugin-react-refresh": "^0.3.4", 25 | "typescript": "^5.0.2", 26 | "vite": "^4.3.9" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { getAuth, connectAuthEmulator } from 'firebase/auth'; 2 | import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore'; 3 | import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; 4 | import { getStorage, connectStorageEmulator } from 'firebase/storage'; 5 | import { Routes, Route } from 'react-router-dom'; 6 | import { 7 | useFirebaseApp, 8 | FirestoreProvider, 9 | StorageProvider, 10 | AuthProvider, 11 | FunctionsProvider, 12 | } from 'reactfire'; 13 | import Header from './components/header'; 14 | import Home from './pages/home'; 15 | import Restaurant from './pages/restaurant'; 16 | 17 | function App() { 18 | 19 | return ( 20 | 21 | }> 22 | } /> 23 | } /> 24 | 25 | 26 | ); 27 | } 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/arrowDown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/food.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/location.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/menu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/price.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/review.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 20 | 21 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/assets/sortBy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/firebase-config.tsx: -------------------------------------------------------------------------------- 1 | export const firebaseConfig = { 2 | // Your Firebase configs 3 | apiKey: 'API_KEY', 4 | authDomain: 'PROJECT_ID.firebaseapp.com', 5 | databaseURL: 'https://PROJECT_ID.firebaseio.com', 6 | projectId: 'PROJECT_ID', 7 | storageBucket: 'PROJECT_ID.appspot.com', 8 | messagingSenderId: 'SENDER_ID', 9 | appId: 'APP_ID', 10 | measurementId: 'G-MEASUREMENT_ID', 11 | }; 12 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .dropdown:hover .dropdown-menu { 6 | display: block; 7 | } 8 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './app.tsx'; 4 | import './index.css'; 5 | import { FirebaseAppProvider } from 'reactfire'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | import { firebaseConfig } from './firebase-config.tsx'; 8 | 9 | const root = createRoot(document.getElementById('root') as HTMLElement); 10 | root.render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/pages/home.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useFirestoreCollectionData, useFirestore } from 'reactfire'; 3 | import { where, collection, orderBy, query } from 'firebase/firestore'; 4 | import FilterModal from '../components/filterModal'; 5 | import RestaurantCards from '../components/restaurantCards'; 6 | 7 | const Home = () => { 8 | const [filters, setFilters] = useState({ 9 | category: '', 10 | city: '', 11 | price: '', 12 | sort: 'Rating', 13 | }); 14 | 15 | // Read from Firestore 16 | const firestore = null; 17 | 18 | const getFilteredRestaurants = () => { 19 | // TODO: complete function 20 | }; 21 | 22 | const { data: restaurants } = { data: [] }; 23 | 24 | useEffect(() => { 25 | console.log('fetching filters...'); 26 | }, [filters]); 27 | 28 | const updateField = (type: string, value: string) => { 29 | setFilters({ ...filters, [type]: value }); 30 | }; 31 | 32 | const [displayCol, setDisplayCol] = useState(true); 33 | return ( 34 |
    35 |
    36 |
    37 | 38 | 47 |
    48 |
    49 | {Object.entries(filters).map(([type, value]) => { 50 | if (type == 'sort' || value == '') { 51 | return null; 52 | } 53 | return ( 54 | 60 | ); 61 | })} 62 |
    63 | 64 |
    65 |
    66 | ); 67 | }; 68 | 69 | export default Home; 70 | 71 | const Tag = ({ type, value, updateField }: any) => { 72 | return ( 73 | 77 | {value} 78 | 100 | 101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /reactfire-start/hosting/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /reactfire-start/hosting/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | "paths": { 17 | "react": [ "./node_modules/@types/react" ] 18 | }, 19 | "allowSyntheticDefaultImports" : true, 20 | 21 | /* Linting */ 22 | "strict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noFallthroughCasesInSwitch": true 26 | }, 27 | "include": ["src"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /reactfire-start/hosting/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /reactfire-start/hosting/vite.config.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: import('vite').UserConfigExport; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /reactfire-start/hosting/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | // https://vitejs.dev/config/ 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }); 7 | -------------------------------------------------------------------------------- /reactfire-start/hosting/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /reactfire-start/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@headlessui/react": "^1.7.14", 4 | "@react-rxjs/core": "^0.10.4", 5 | "@react-rxjs/utils": "^0.9.5", 6 | "firebase": "^9.22.1", 7 | "firebase-functions": "^4.4.1", 8 | "react-router-dom": "^6.11.2", 9 | "reactfire": "^4.2.2", 10 | "rxjs": "^7.8.1" 11 | }, 12 | "devDependencies": { 13 | "tailwindcss": "^3.3.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /reactfire-start/storage.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | 3 | // Craft rules based on data in your Firestore database 4 | // allow write: if firestore.get( 5 | // /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin; 6 | service firebase.storage { 7 | match /b/{bucket}/o { 8 | match /{allPaths=**} { 9 | allow read, write: if false; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /reactfire-start/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./hosting/src/**/*.{html,js,tsx,ts}", "./hosting/src/***/**/*.{html,js,tsx,ts}", "./hosting/src/*.{html,js,tsx,ts}", "./hosting/node_modules/tw-elements/dist/js/**/*.js",], 4 | theme: { 5 | extend: { 6 | colors: { 7 | 'navy': { 8 | 10: '#F6F7F9', 9 | 20: '#E5EAF0', 10 | 30: '#D4DCE7', 11 | 40: '#C3CFDD', 12 | 50: '#B2C1D4', 13 | 100: '#8EA1B9', 14 | 200: '#6B829D', 15 | 300: '#476282', 16 | 400: '#385574', 17 | 500: '#2A4865', 18 | 600: '#1B3A57', 19 | 700: '#0C2D48', 20 | 800: '#051E34', 21 | 900: '#031525', 22 | }, 23 | 'amber': { 24 | 50: '#FFF8E1', 25 | 100: '#FFECB3', 26 | 200: '#FFE082', 27 | 300: '#FFD54F', 28 | 400: '#FFCA28', 29 | 500: '#FFC107', 30 | 600: '#FFB300', 31 | 700: '#FFA000', 32 | 800: '#FF8F00', 33 | 900: '#FF6F00', 34 | A100: '#FFE57F', 35 | A200: '#FFD740', 36 | A400: '#FFC400', 37 | A700: '#FFAB00', 38 | } 39 | }, 40 | }, 41 | 42 | }, 43 | plugins: [], 44 | } 45 | 46 | -------------------------------------------------------------------------------- /vanilla-js/docs/finished_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/docs/finished_image.png -------------------------------------------------------------------------------- /vanilla-js/firebase-messaging-sw.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | importScripts('/__/firebase/9.2.0/firebase-app-compat.js'); 17 | importScripts('/__/firebase/9.2.0/firebase-messaging-compat.js'); 18 | importScripts('/__/firebase/init.js'); 19 | 20 | firebase.messaging(); 21 | -------------------------------------------------------------------------------- /vanilla-js/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "./", 4 | "ignore": [ 5 | "firebase.json", 6 | "database-rules.json", 7 | "storage.rules", 8 | "functions" 9 | ], 10 | "headers": [ 11 | { 12 | "source": "**/*.@(js|html)", 13 | "headers": [ 14 | { 15 | "key": "Cache-Control", 16 | "value": "max-age=0" 17 | } 18 | ] 19 | } 20 | ], 21 | "rewrites": [ 22 | { 23 | "source": "**", 24 | "destination": "/index.html" 25 | } 26 | ] 27 | }, 28 | "firestore": { 29 | "rules": "firestore.rules", 30 | "indexes": "firestore.indexes.json" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /vanilla-js/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "restaurants", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "price", 9 | "order": "ASCENDING" 10 | }, 11 | { 12 | "fieldPath": "avgRating", 13 | "order": "DESCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "restaurants", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "city", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "avgRating", 27 | "order": "DESCENDING" 28 | } 29 | ] 30 | }, 31 | { 32 | "collectionGroup": "restaurants", 33 | "queryScope": "COLLECTION", 34 | "fields": [ 35 | { 36 | "fieldPath": "price", 37 | "order": "ASCENDING" 38 | }, 39 | { 40 | "fieldPath": "numRatings", 41 | "order": "DESCENDING" 42 | } 43 | ] 44 | }, 45 | { 46 | "collectionGroup": "restaurants", 47 | "queryScope": "COLLECTION", 48 | "fields": [ 49 | { 50 | "fieldPath": "city", 51 | "order": "ASCENDING" 52 | }, 53 | { 54 | "fieldPath": "numRatings", 55 | "order": "DESCENDING" 56 | } 57 | ] 58 | }, 59 | { 60 | "collectionGroup": "restaurants", 61 | "queryScope": "COLLECTION", 62 | "fields": [ 63 | { 64 | "fieldPath": "category", 65 | "order": "ASCENDING" 66 | }, 67 | { 68 | "fieldPath": "avgRating", 69 | "order": "DESCENDING" 70 | } 71 | ] 72 | }, 73 | { 74 | "collectionGroup": "restaurants", 75 | "queryScope": "COLLECTION", 76 | "fields": [ 77 | { 78 | "fieldPath": "category", 79 | "order": "ASCENDING" 80 | }, 81 | { 82 | "fieldPath": "price", 83 | "order": "ASCENDING" 84 | } 85 | ] 86 | }, 87 | { 88 | "collectionGroup": "restaurants", 89 | "queryScope": "COLLECTION", 90 | "fields": [ 91 | { 92 | "fieldPath": "city", 93 | "order": "ASCENDING" 94 | }, 95 | { 96 | "fieldPath": "price", 97 | "order": "ASCENDING" 98 | } 99 | ] 100 | }, 101 | { 102 | "collectionGroup": "restaurants", 103 | "queryScope": "COLLECTION", 104 | "fields": [ 105 | { 106 | "fieldPath": "category", 107 | "order": "ASCENDING" 108 | }, 109 | { 110 | "fieldPath": "numRatings", 111 | "order": "DESCENDING" 112 | } 113 | ] 114 | } 115 | ], 116 | "fieldOverrides": [] 117 | } 118 | -------------------------------------------------------------------------------- /vanilla-js/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | 4 | // Determine if the value of the field "key" is the same 5 | // before and after the request. 6 | function unchanged(key) { 7 | return (key in resource.data) 8 | && (key in request.resource.data) 9 | && (resource.data[key] == request.resource.data[key]); 10 | } 11 | 12 | match /databases/{database}/documents { 13 | // Restaurants: 14 | // - Authenticated user can read 15 | // - Authenticated user can create/update (for demo purposes only) 16 | // - Updates are allowed if no fields are added and name is unchanged 17 | // - Deletes are not allowed (default) 18 | match /restaurants/{restaurantId} { 19 | allow read: if request.auth != null; 20 | allow create: if request.auth != null; 21 | allow update: if request.auth != null 22 | && (request.resource.data.keys() == resource.data.keys()) 23 | && unchanged("name"); 24 | 25 | // Ratings: 26 | // - Authenticated user can read 27 | // - Authenticated user can create if userId matches 28 | // - Deletes and updates are not allowed (default) 29 | match /ratings/{ratingId} { 30 | allow read: if request.auth != null; 31 | allow create: if request.auth != null 32 | && request.resource.data.userId == request.auth.uid; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /vanilla-js/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/images/favicon.ico -------------------------------------------------------------------------------- /vanilla-js/images/guy_fireats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/images/guy_fireats.png -------------------------------------------------------------------------------- /vanilla-js/images/icons/fireeats-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/images/icons/fireeats-192x192.png -------------------------------------------------------------------------------- /vanilla-js/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "FriendlyEats", 3 | "short_name": "FriendlyEats", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "orientation": "portrait", 7 | "icons": [{ 8 | "src": "images/icons/fireeats-192x192.png", 9 | "sizes": "192x192", 10 | "type": "image/png" 11 | }], 12 | "theme_color": "#B71C1C" 13 | } 14 | -------------------------------------------------------------------------------- /vanilla-js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fireats", 3 | "version": "0.0.0", 4 | "description": "Starting point for the Cloud Firestore codelab", 5 | "main": ".", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "fmt": "prettier --write scripts/* styles/*" 9 | }, 10 | "author": "", 11 | "devDependencies": { 12 | "eslint": "^4.17.0", 13 | "prettier": "^1.5.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /vanilla-js/scripts/FriendlyEats.Data.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 'use strict'; 17 | 18 | FriendlyEats.prototype.addRestaurant = function(data) { 19 | /* 20 | TODO: Implement adding a document 21 | */ 22 | }; 23 | 24 | FriendlyEats.prototype.getAllRestaurants = function(renderer) { 25 | /* 26 | TODO: Retrieve list of restaurants 27 | */ 28 | }; 29 | 30 | FriendlyEats.prototype.getDocumentsInQuery = function(query, renderer) { 31 | /* 32 | TODO: Render all documents in the provided query 33 | */ 34 | }; 35 | 36 | FriendlyEats.prototype.getRestaurant = function(id) { 37 | /* 38 | TODO: Retrieve a single restaurant 39 | */ 40 | }; 41 | 42 | FriendlyEats.prototype.getFilteredRestaurants = function(filters, renderer) { 43 | /* 44 | TODO: Retrieve filtered list of restaurants 45 | */ 46 | }; 47 | 48 | FriendlyEats.prototype.addRating = function(restaurantID, rating) { 49 | /* 50 | TODO: Retrieve add a rating to a restaurant 51 | */ 52 | }; 53 | -------------------------------------------------------------------------------- /vanilla-js/scripts/FriendlyEats.Mock.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 'use strict'; 17 | 18 | /** 19 | * Adds a set of mock Restaurants to the Cloud Firestore. 20 | */ 21 | FriendlyEats.prototype.addMockRestaurants = function() { 22 | var promises = []; 23 | 24 | for (var i = 0; i < 20; i++) { 25 | var name = 26 | this.getRandomItem(this.data.words) + 27 | ' ' + 28 | this.getRandomItem(this.data.words); 29 | var category = this.getRandomItem(this.data.categories); 30 | var city = this.getRandomItem(this.data.cities); 31 | var price = Math.floor(Math.random() * 4) + 1; 32 | var photoID = Math.floor(Math.random() * 22) + 1; 33 | var photo = 'https://storage.googleapis.com/firestorequickstarts.appspot.com/food_' + photoID + '.png'; 34 | var numRatings = 0; 35 | var avgRating = 0; 36 | 37 | var promise = this.addRestaurant({ 38 | name: name, 39 | category: category, 40 | price: price, 41 | city: city, 42 | numRatings: numRatings, 43 | avgRating: avgRating, 44 | photo: photo 45 | }); 46 | 47 | if (!promise) { 48 | alert('addRestaurant() is not implemented yet!'); 49 | return Promise.reject(); 50 | } else { 51 | promises.push(promise); 52 | } 53 | } 54 | 55 | return Promise.all(promises); 56 | }; 57 | 58 | /** 59 | * Adds a set of mock Ratings to the given Restaurant. 60 | */ 61 | FriendlyEats.prototype.addMockRatings = function(restaurantID) { 62 | var ratingPromises = []; 63 | for (var r = 0; r < 5*Math.random(); r++) { 64 | var rating = this.data.ratings[ 65 | parseInt(this.data.ratings.length*Math.random()) 66 | ]; 67 | rating.userName = 'Bot (Web)'; 68 | rating.timestamp = new Date(); 69 | rating.userId = firebase.auth().currentUser.uid; 70 | ratingPromises.push(this.addRating(restaurantID, rating)); 71 | } 72 | return Promise.all(ratingPromises); 73 | }; -------------------------------------------------------------------------------- /vanilla-js/scripts/FriendlyEats.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 'use strict'; 17 | 18 | /** 19 | * Initializes the FriendlyEats app. 20 | */ 21 | function FriendlyEats() { 22 | const isLocalhost = Boolean( 23 | window.location.hostname === 'localhost' || 24 | // [::1] is the IPv6 localhost address. 25 | window.location.hostname === '[::1]' || 26 | // 127.0.0.1/8 is considered localhost for IPv4. 27 | window.location.hostname.match( 28 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 29 | ) 30 | ); 31 | if(isLocalhost) { 32 | /* 33 | TODO: Set up local debug token 34 | */ 35 | } 36 | 37 | this.filters = { 38 | city: '', 39 | price: '', 40 | category: '', 41 | sort: 'Rating' 42 | }; 43 | 44 | this.dialogs = {}; 45 | 46 | var that = this; 47 | that.initAppCheck(); 48 | 49 | firebase.auth().signInAnonymously().then(function() { 50 | that.initTemplates(); 51 | that.initRouter(); 52 | that.initReviewDialog(); 53 | that.initFilterDialog(); 54 | }).catch(function(err) { 55 | console.log(err); 56 | }); 57 | } 58 | 59 | /** 60 | * Initializes the router for the FriendlyEats app. 61 | */ 62 | FriendlyEats.prototype.initRouter = function() { 63 | this.router = new Navigo(); 64 | 65 | var that = this; 66 | this.router 67 | .on({ 68 | '/': function() { 69 | that.updateQuery(that.filters); 70 | } 71 | }) 72 | .on({ 73 | '/setup': function() { 74 | that.viewSetup(); 75 | } 76 | }) 77 | .on({ 78 | '/restaurants/*': function() { 79 | var path = that.getCleanPath(document.location.pathname); 80 | var id = path.split('/')[2]; 81 | that.viewRestaurant(id); 82 | } 83 | }) 84 | .resolve(); 85 | 86 | firebase 87 | .firestore() 88 | .collection('restaurants') 89 | .limit(1) 90 | .onSnapshot(function(snapshot) { 91 | if (snapshot.empty) { 92 | that.router.navigate('/setup'); 93 | } 94 | }); 95 | }; 96 | 97 | FriendlyEats.prototype.getCleanPath = function(dirtyPath) { 98 | if (dirtyPath.startsWith('/index.html')) { 99 | return dirtyPath.split('/').slice(1).join('/'); 100 | } else { 101 | return dirtyPath; 102 | } 103 | }; 104 | 105 | FriendlyEats.prototype.getFirebaseConfig = function() { 106 | return firebase.app().options; 107 | }; 108 | 109 | FriendlyEats.prototype.getRandomItem = function(arr) { 110 | return arr[Math.floor(Math.random() * arr.length)]; 111 | }; 112 | 113 | FriendlyEats.prototype.data = { 114 | words: [ 115 | 'Bar', 116 | 'Fire', 117 | 'Grill', 118 | 'Drive Thru', 119 | 'Place', 120 | 'Best', 121 | 'Spot', 122 | 'Prime', 123 | 'Eatin\'' 124 | ], 125 | cities: [ 126 | 'Albuquerque', 127 | 'Arlington', 128 | 'Atlanta', 129 | 'Austin', 130 | 'Baltimore', 131 | 'Boston', 132 | 'Charlotte', 133 | 'Chicago', 134 | 'Cleveland', 135 | 'Colorado Springs', 136 | 'Columbus', 137 | 'Dallas', 138 | 'Denver', 139 | 'Detroit', 140 | 'El Paso', 141 | 'Fort Worth', 142 | 'Fresno', 143 | 'Houston', 144 | 'Indianapolis', 145 | 'Jacksonville', 146 | 'Kansas City', 147 | 'Las Vegas', 148 | 'Long Island', 149 | 'Los Angeles', 150 | 'Louisville', 151 | 'Memphis', 152 | 'Mesa', 153 | 'Miami', 154 | 'Milwaukee', 155 | 'Nashville', 156 | 'New York', 157 | 'Oakland', 158 | 'Oklahoma', 159 | 'Omaha', 160 | 'Philadelphia', 161 | 'Phoenix', 162 | 'Portland', 163 | 'Raleigh', 164 | 'Sacramento', 165 | 'San Antonio', 166 | 'San Diego', 167 | 'San Francisco', 168 | 'San Jose', 169 | 'Tucson', 170 | 'Tulsa', 171 | 'Virginia Beach', 172 | 'Washington' 173 | ], 174 | categories: [ 175 | 'Brunch', 176 | 'Burgers', 177 | 'Coffee', 178 | 'Deli', 179 | 'Dim Sum', 180 | 'Indian', 181 | 'Italian', 182 | 'Mediterranean', 183 | 'Mexican', 184 | 'Pizza', 185 | 'Ramen', 186 | 'Sushi' 187 | ], 188 | ratings: [ 189 | { 190 | rating: 1, 191 | text: 'Would never eat here again!' 192 | }, 193 | { 194 | rating: 2, 195 | text: 'Not my cup of tea.' 196 | }, 197 | { 198 | rating: 3, 199 | text: 'Exactly okay :/' 200 | }, 201 | { 202 | rating: 4, 203 | text: 'Actually pretty good, would recommend!' 204 | }, 205 | { 206 | rating: 5, 207 | text: 'This is my favorite place. Literally.' 208 | } 209 | ] 210 | }; 211 | 212 | window.onload = function() { 213 | window.app = new FriendlyEats(); 214 | }; 215 | -------------------------------------------------------------------------------- /vanilla-js/steps/.gitignore: -------------------------------------------------------------------------------- 1 | firestore-web 2 | !firestore-web/.firebaserc 3 | !firestore-web/firebase.json 4 | -------------------------------------------------------------------------------- /vanilla-js/steps/img/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/steps/img/img1.png -------------------------------------------------------------------------------- /vanilla-js/steps/img/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/steps/img/img2.png -------------------------------------------------------------------------------- /vanilla-js/steps/img/img3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/steps/img/img3.png -------------------------------------------------------------------------------- /vanilla-js/steps/img/img4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/steps/img/img4.png -------------------------------------------------------------------------------- /vanilla-js/steps/img/img5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/steps/img/img5.png -------------------------------------------------------------------------------- /vanilla-js/steps/img/img6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/steps/img/img6.png -------------------------------------------------------------------------------- /vanilla-js/steps/img/img7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firebase/friendlyeats-web/6bf6c0e7f3c480c6ec4675814351e5a254ca092c/vanilla-js/steps/img/img7.png -------------------------------------------------------------------------------- /vanilla-js/sw.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2017 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ -------------------------------------------------------------------------------- /vanilla-js/test.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | 3 | # Run linter 4 | find . -type f -name "*.js" -not -path "*node_modules*" \ 5 | | xargs eslint --------------------------------------------------------------------------------