├── .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 | [](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 |
13 |
--------------------------------------------------------------------------------
/nextjs-end/public/filter.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/nextjs-end/public/food.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/nextjs-end/public/location.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/nextjs-end/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/nextjs-end/public/price.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/nextjs-end/public/profile.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/nextjs-end/public/review.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
21 |
--------------------------------------------------------------------------------
/nextjs-end/public/sortBy.svg:
--------------------------------------------------------------------------------
1 |
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 |

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 |
40 |
41 |
Restaurants
42 |
Sorted by {filters.sort || "Rating"}
43 |
44 |
45 |
46 |
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 |
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 |
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 |

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-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 |
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 |
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 |
21 |
22 | );
23 | } else {
24 | arr.push(
25 |
26 |
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 |
13 |
--------------------------------------------------------------------------------
/nextjs-start/public/filter.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/nextjs-start/public/food.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/nextjs-start/public/location.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/nextjs-start/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/nextjs-start/public/price.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/nextjs-start/public/profile.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/nextjs-start/public/review.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
21 |
--------------------------------------------------------------------------------
/nextjs-start/public/sortBy.svg:
--------------------------------------------------------------------------------
1 |
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 |

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 |
40 |
41 |
Restaurants
42 |
Sorted by {filters.sort || "Rating"}
43 |
44 |
45 |
46 |
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 |
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 |
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 |

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 |
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 |
7 | {text}
8 |
9 |
14 |
15 | );
16 | }
17 |
18 | export function ReviewSkeleton() {
19 | return (
20 |
21 |
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 |
21 |
22 | );
23 | } else {
24 | arr.push(
25 |
26 |
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 |
13 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/arrowDown.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/filter.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/food.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/location.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/menu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/price.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/review.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
21 |
--------------------------------------------------------------------------------
/reactfire-end/hosting/src/assets/sortBy.svg:
--------------------------------------------------------------------------------
1 |
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 |
13 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/arrowDown.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/filter.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/food.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/location.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/menu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/price.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/review.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
21 |
--------------------------------------------------------------------------------
/reactfire-start/hosting/src/assets/sortBy.svg:
--------------------------------------------------------------------------------
1 |
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
--------------------------------------------------------------------------------