├── app ├── favicon.ico ├── property-search │ ├── @modal │ │ ├── default.tsx │ │ └── (..)login │ │ │ ├── actions.ts │ │ │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ ├── actions.ts │ ├── toggle-favourite-button.tsx │ ├── filters-form.tsx │ └── page.tsx ├── (auth) │ ├── layout.tsx │ ├── login │ │ ├── login-form.tsx │ │ └── page.tsx │ ├── forgot-password │ │ ├── page.tsx │ │ └── forgot-password-form.tsx │ └── register │ │ ├── page.tsx │ │ ├── actions.ts │ │ └── register-form.tsx ├── admin-dashboard │ ├── layout.tsx │ ├── new │ │ ├── page.tsx │ │ ├── actions.ts │ │ └── new-property-form.tsx │ ├── actions.ts │ ├── edit │ │ └── [propertyId] │ │ │ ├── actions.ts │ │ │ ├── page.tsx │ │ │ └── edit-property-form.tsx │ ├── page.tsx │ └── properties-table.tsx ├── property │ └── [propertyId] │ │ ├── back-button.tsx │ │ └── page.tsx ├── account │ ├── actions.ts │ ├── my-favourites │ │ ├── remove-favourite-button.tsx │ │ └── page.tsx │ ├── page.tsx │ ├── delete-account-button.tsx │ └── update-password-form.tsx ├── page.tsx ├── api │ └── refresh-token │ │ └── route.ts ├── layout.tsx └── globals.css ├── public ├── hero.webp ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── types ├── propertyStatus.ts └── property.ts ├── postcss.config.mjs ├── lib ├── utils.ts └── imageUrlFormatter.ts ├── .eslintrc.json ├── components ├── ui │ ├── skeleton.tsx │ ├── label.tsx │ ├── textarea.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── badge.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── table.tsx │ ├── breadcrumb.tsx │ ├── dialog.tsx │ ├── form.tsx │ ├── alert-dialog.tsx │ ├── select.tsx │ ├── carousel.tsx │ └── dropdown-menu.tsx ├── continue-with-google-button.tsx ├── property-status-badge.tsx ├── auth-buttons.tsx ├── login-form.tsx ├── multi-image-uploader.tsx └── property-form.tsx ├── next.config.ts ├── components.json ├── .gitignore ├── eslint.config.mjs ├── tsconfig.json ├── data ├── favourites.ts └── properties.ts ├── validation ├── registerUser.ts └── propertySchema.ts ├── firebase ├── client.ts └── server.ts ├── context ├── actions.ts └── auth.tsx ├── README.md ├── package.json ├── tailwind.config.ts └── middleware.ts /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomphill/fire-homes-course/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/hero.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomphill/fire-homes-course/HEAD/public/hero.webp -------------------------------------------------------------------------------- /app/property-search/@modal/default.tsx: -------------------------------------------------------------------------------- 1 | export default function Default() { 2 | return null; 3 | } 4 | -------------------------------------------------------------------------------- /types/propertyStatus.ts: -------------------------------------------------------------------------------- 1 | export type PropertyStatus = "for-sale" | "draft" | "withdrawn" | "sold"; 2 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function Layout({ children }: { children: React.ReactNode }) { 2 | return
{children}
; 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /app/admin-dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function Layout({ children }: { children: React.ReactNode }) { 2 | return
{children}
; 3 | } 4 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/property-search/@modal/(..)login/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | 5 | export const loginSuccess = async () => { 6 | revalidatePath("/property-search"); 7 | }; 8 | -------------------------------------------------------------------------------- /lib/imageUrlFormatter.ts: -------------------------------------------------------------------------------- 1 | export default function imageUrlFormatter(imagePath: string) { 2 | return `https://firebasestorage.googleapis.com/v0/b/fire-homes-course.appspot.com/o/${encodeURIComponent( 3 | imagePath 4 | )}?alt=media`; 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"], 3 | "rules": { 4 | "@typescript-eslint/no-unused-vars": "off", 5 | "@typescript-eslint/no-explicit-any": "off", 6 | "@typescript-eslint/no-empty-object-type": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/property-search/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function Layout({ 2 | children, 3 | modal, 4 | }: { 5 | children: React.ReactNode; 6 | modal: React.ReactNode; 7 | }) { 8 | return ( 9 | <> 10 | {children} 11 | {modal} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /types/property.ts: -------------------------------------------------------------------------------- 1 | import { PropertyStatus } from "./propertyStatus"; 2 | 3 | export type Property = { 4 | id: string; 5 | address1: string; 6 | address2?: string; 7 | city: string; 8 | postcode: string; 9 | price: number; 10 | bedrooms: number; 11 | bathrooms: number; 12 | description: string; 13 | status: PropertyStatus; 14 | images?: string[]; 15 | }; 16 | -------------------------------------------------------------------------------- /app/(auth)/login/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import CommonLoginForm from "@/components/login-form"; 5 | 6 | export default function LoginForm() { 7 | const router = useRouter(); 8 | 9 | return ( 10 | { 12 | router.refresh(); 13 | }} 14 | /> 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/property/[propertyId]/back-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { ArrowLeftIcon } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | export default function BackButton() { 8 | const router = useRouter(); 9 | return ( 10 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | images: { 6 | remotePatterns: [ 7 | { 8 | protocol: "https", 9 | hostname: "lh3.googleusercontent.com", 10 | }, 11 | { 12 | protocol: "https", 13 | hostname: "firebasestorage.googleapis.com", 14 | }, 15 | ], 16 | }, 17 | }; 18 | 19 | export default nextConfig; 20 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /app/account/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth, firestore } from "@/firebase/server"; 4 | import { cookies } from "next/headers"; 5 | 6 | export const deleteUserFavourites = async () => { 7 | const cookieStore = await cookies(); 8 | const token = cookieStore.get("firebaseAuthToken")?.value; 9 | 10 | if (!token) { 11 | return; 12 | } 13 | 14 | try { 15 | const decodedToken = await auth.verifyIdToken(token); 16 | await firestore.collection("favourites").doc(decodedToken.uid).delete(); 17 | } catch (e) {} 18 | }; 19 | -------------------------------------------------------------------------------- /app/property-search/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { HomeIcon } from "lucide-react"; 3 | 4 | export default function Loading() { 5 | return ( 6 | <> 7 |
8 |

Property Search

9 |
10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for commiting if needed) 33 | .env* 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | -------------------------------------------------------------------------------- /components/continue-with-google-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { Button } from "./ui/button"; 5 | import { useAuth } from "@/context/auth"; 6 | 7 | export default function ContinueWithGoogleButton() { 8 | const auth = useAuth(); 9 | const router = useRouter(); 10 | 11 | return ( 12 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | rules: { 16 | "@typescript-eslint/no-unused-vars": "off", 17 | "@typescript-eslint/no-explicit-any": "off", 18 | "@typescript-eslint/no-empty-object-type": "off", 19 | }, 20 | }, 21 | ]; 22 | 23 | export default eslintConfig; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /data/favourites.ts: -------------------------------------------------------------------------------- 1 | import { auth, firestore } from "@/firebase/server"; 2 | import { cookies } from "next/headers"; 3 | import "server-only"; 4 | 5 | export const getUserFavourites = async () => { 6 | const cookieStore = await cookies(); 7 | const token = cookieStore.get("firebaseAuthToken")?.value; 8 | 9 | if (!token) { 10 | return {}; 11 | } 12 | 13 | const verifiedToken = await auth.verifyIdToken(token); 14 | 15 | if (!verifiedToken) { 16 | return {}; 17 | } 18 | 19 | const favouritesSnapshot = await firestore 20 | .collection("favourites") 21 | .doc(verifiedToken.uid) 22 | .get(); 23 | 24 | const favouritesData = favouritesSnapshot.data(); 25 | return favouritesData || {}; 26 | }; 27 | -------------------------------------------------------------------------------- /app/(auth)/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | CardTitle, 7 | } from "@/components/ui/card"; 8 | import ForgotPasswordForm from "./forgot-password-form"; 9 | 10 | export default function ForgotPassword() { 11 | return ( 12 | 13 | 14 | Forgot Password 15 | 16 | Enter your email address below and we will send you a link to reset 17 | your password 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardFooter, 5 | CardHeader, 6 | CardTitle, 7 | } from "@/components/ui/card"; 8 | import LoginForm from "./login-form"; 9 | import Link from "next/link"; 10 | 11 | export default function Login() { 12 | return ( 13 | 14 | 15 | Login 16 | 17 | 18 | 19 | 20 | 21 | Don't have an account? 22 | 23 | Register here. 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardFooter, 5 | CardHeader, 6 | CardTitle, 7 | } from "@/components/ui/card"; 8 | import RegisterForm from "./register-form"; 9 | import Link from "next/link"; 10 | 11 | export default function Register() { 12 | return ( 13 | 14 | 15 | Register 16 | 17 | 18 | 19 | 20 | 21 | Already have an account? 22 | 23 | Log in here 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/property-status-badge.tsx: -------------------------------------------------------------------------------- 1 | import { PropertyStatus } from "@/types/propertyStatus"; 2 | import { Badge } from "./ui/badge"; 3 | 4 | const statusLabel = { 5 | "for-sale": "For Sale", 6 | withdrawn: "Withdrawn", 7 | draft: "Draft", 8 | sold: "Sold", 9 | }; 10 | 11 | const variant: { 12 | [key: string]: "primary" | "destructive" | "secondary" | "success"; 13 | } = { 14 | "for-sale": "primary", 15 | withdrawn: "destructive", 16 | draft: "secondary", 17 | sold: "success", 18 | }; 19 | 20 | export default function PropertyStatusBadge({ 21 | status, 22 | className, 23 | }: { 24 | status: PropertyStatus; 25 | className?: string; 26 | }) { 27 | const label = statusLabel[status]; 28 | return ( 29 | 30 | {label} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |