diff --git a/frontend/README.md b/frontend/README.md index 08868d1d..dab37cf0 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -20,3 +20,5 @@ A web-based portal for organizations to reach Penn Mobile users. you should be able to see the site at `localhost:3000`! 1. The backend should be running at `localhost:8000`. We proxy all requests from localhost:3000/api to localhost:8000/api (in `frontend/server.js`), so you can make requests to the backend from the frontend. If you want to directly see what the request should return, you can go to `localhost:8000/api/...` to see the response. 1. There's also some jankiness with login since we make requests to clubs and accounts -- for login to work in your dev environment, you'll need to go to `localhost:8000/admin` and add a valid access token (make sure the expiration date is some day in the far future). + +kek \ No newline at end of file diff --git a/sublet/.eslintrc.json b/sublet/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/sublet/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/sublet/.gitignore b/sublet/.gitignore new file mode 100644 index 00000000..fd3dbb57 --- /dev/null +++ b/sublet/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/sublet/Pipfile b/sublet/Pipfile new file mode 100644 index 00000000..645a67ea --- /dev/null +++ b/sublet/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.12" diff --git a/sublet/README.md b/sublet/README.md new file mode 100644 index 00000000..479cabaa --- /dev/null +++ b/sublet/README.md @@ -0,0 +1,24 @@ +# Penn Mobile Subletting Feature + +Welcome to the Penn Mobile Subletting Feature! This innovative marketplace is designed to enhance the subletting experience for the University of Pennsylvania community, offering a user-friendly platform to browse, create, and manage sublet listings with ease. + +## Features + +- **Marketplace Dashboard:** Browse through a wide range of sublet listings in a user-friendly dashboard. Find the perfect match for your subletting needs with detailed information and intuitive navigation. + +- **Personal Listings Management:** Have complete control over your listings through your personal dashboard. Create new listings, edit existing ones, and manage all your subletting activities in one place. + +- **Direct Communication:** Reach out directly to the owners of the listings you are interested in. Our platform facilitates seamless communication between parties to ensure smooth transactions. + +- **User-Friendly Interface:** Built with the Next.js framework and styled using Tailwind CSS along with ShadCN for a modern, responsive design that enhances user experience across devices. + +## Getting Started + +To run the Penn Mobile Subletting Feature locally, just do: + +`npm run dev` + + +## Attributions + +Font: [Satoshi](https://www.fontshare.com/fonts/satoshi) by Indian Type Foundry (ITF) - [ITF Free Font License (FFL)](https://www.fontshare.com/licenses/itf-free-font-license) diff --git a/sublet/app/create/page.tsx b/sublet/app/create/page.tsx new file mode 100644 index 00000000..f516f3f0 --- /dev/null +++ b/sublet/app/create/page.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const CreateListing = () => { + return ( +
CreateListing
+ ) +} + +export default CreateListing \ No newline at end of file diff --git a/sublet/app/dashboard/page.tsx b/sublet/app/dashboard/page.tsx new file mode 100644 index 00000000..ed815c44 --- /dev/null +++ b/sublet/app/dashboard/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState, useEffect } from "react"; + +import { fetchProperties } from "../../services/propertyService"; +import { PropertyInterface } from "@/interfaces/Property"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { Button } from "@/components/ui/button"; + +import { PlusIcon } from "lucide-react"; + +import PropertyList from "@/components/custom/PropertyList"; + +import PropertyForm from "@/components/custom/PropertyForm"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import Image from "next/image"; + +const Dashboard = () => { + const [properties, setProperties] = useState([]); + + useEffect(() => { + fetchProperties() + .then((data) => { + setProperties(data); + }) + .catch((error) => { + console.error("Error fetching properties:", error); + }); + }, []); + + return ( +
+ + + +
+ + Posted + Drafts + + + + +
+ + + +
+
+

+ My Listings +

+ +
+
+
+ + + +
+
+

+ My Drafts +

+ +
+
+
+
+
+ ); +}; + +export default Dashboard; diff --git a/sublet/app/globals.css b/sublet/app/globals.css new file mode 100644 index 00000000..b1847b18 --- /dev/null +++ b/sublet/app/globals.css @@ -0,0 +1,77 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 4% 16%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --primary: 219 100% 69%; + --primary-foreground: 0 0% 98%; + + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 219 92% 58%; + + --radius: 0.5rem; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/sublet/app/layout.tsx b/sublet/app/layout.tsx new file mode 100644 index 00000000..448d9744 --- /dev/null +++ b/sublet/app/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; + +import localFont from "next/font/local"; +//import { Inter } from "next/font/google"; +import "./globals.css"; + +//const inter = Inter({ subsets: ["latin"] }); + +import { Toaster } from "@/components/ui/toaster" + +const satoshi = localFont({ + src: '../fonts/satoshi/Satoshi-Variable.woff2', + display: 'swap', +}) + +export const metadata: Metadata = { + title: "Sublet@Portal", + description: "Welcome to Sublet@Portal. The best place to sublet your room to other students!", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +
+ {children} +
+ + + + ); +} diff --git a/sublet/app/page.tsx b/sublet/app/page.tsx new file mode 100644 index 00000000..ceae06f7 --- /dev/null +++ b/sublet/app/page.tsx @@ -0,0 +1,19 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+

+ + Welcome to Sublet@Portal + +

+
+
+ Sublet@Portal Logo +
+

The best place to sublet your room to other students!

+
+
+ ); +} diff --git a/sublet/components.json b/sublet/components.json new file mode 100644 index 00000000..58b812d0 --- /dev/null +++ b/sublet/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/sublet/components/custom/Property.tsx b/sublet/components/custom/Property.tsx new file mode 100644 index 00000000..fc9a6645 --- /dev/null +++ b/sublet/components/custom/Property.tsx @@ -0,0 +1,169 @@ +{/* +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; + +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +import { CheckIcon, DotsHorizontalIcon as Dots } from "@radix-ui/react-icons"; +*/} + +import { CalendarIcon, ImageIcon } from "@radix-ui/react-icons"; +import { MapPinIcon, DollarSignIcon } from 'lucide-react'; + +import { Card, CardContent } from "@/components/ui/card" +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; + +import Image from "next/image"; + +import { PropertyInterface, ImageInterface } from "@/interfaces/Property"; +import { AspectRatio } from "../ui/aspect-ratio"; +import { Skeleton } from "../ui/skeleton"; + +function formatDate(dateString: string): string { + // Convert the input string to a Date object + const date = new Date(dateString); + + // Format the date to the desired format + const formattedDate = date.toLocaleDateString('en-US', { + month: 'short', // Abbreviated month name + day: 'numeric' // Numeric day of the month + }); + + return formattedDate; +} + +interface PropertyProps { + property: PropertyInterface; +} + +const Property: React.FC = ({ property }) => { + return ( +
+ +
+ + +
+ + {property.images.length > 0 ? + + + {property.images.map((_, index) => ( + + + Property image + + + ))} + + + + + : + + + + + + } + +
+
{property.title}
+ {/* +
+
+ + +
+

Pending

+
+ */} + + {/* + + + + + + Delete + + + */} +
+ +
+
+ +

{formatDate(property.start_date)} - {formatDate(property.end_date)}

+
+
+ +

{property.address?.split(',')[0]}

+
+
+ +

+ + {property.price} + + /month +

+
+ +
+ {/*
+ + + + + +
*/} +
+ ); +}; + +export default Property; diff --git a/sublet/components/custom/PropertyForm.tsx b/sublet/components/custom/PropertyForm.tsx new file mode 100644 index 00000000..aa12f63b --- /dev/null +++ b/sublet/components/custom/PropertyForm.tsx @@ -0,0 +1,572 @@ +"use client" + +import { fetchAmenities, createProperty, createPropertyImage, fetchProperties } from "@/services/propertyService" + +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { ChangeEvent } from "react"; +//import { useToast } from "@/components/ui/use-toast" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label"; +import { CalendarIcon, ImageIcon } from "@radix-ui/react-icons"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Calendar } from "../ui/calendar"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { Textarea } from "../ui/textarea"; +import { useEffect, useState } from "react" +import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group" +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar" + +import Image from 'next/image' +import { AspectRatio } from "../ui/aspect-ratio" +import { Skeleton } from "../ui/skeleton" + +const uriRegex = new RegExp('^(https?:\/\/)(localhost|[\da-z\.-]+)\.([a-z\.]{2,6}|[0-9]{1,5})([\/\w \.-]*)*\/?$'); + +const decimalRegex = /^-?\d+(\.\d)?$/; + +const MAX_UPLOAD_SIZE = 1024 * 1024 * 3; // 3MB for each file +const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png']; // Accepted image formats +const MAX_FILES = 6; // Maximum number of images + +const formSchema = z.object({ + amenities: z.array(z.string().max(255)), + title: z.string().max(255), + address: z.string().max(255), + beds: z.preprocess((value) => { + // If value is already a number, return it + if (typeof value === 'number') return value; + // If it's a string, attempt to parse it + if (typeof value === 'string') return parseFloat(value); + }, z.number().int().min(0).max(2147483647)), + baths: z.union([ + z.string().regex(decimalRegex).optional().transform((val) => val !== undefined ? parseFloat(val) : undefined), + z.number().min(0).max(100).optional() + ]).refine((val) => val === undefined || (typeof val === 'number' && val >= 0 && val <= 100 && decimalRegex.test(val.toString())), { + message: "Baths must be a decimal with at most one decimal place and within the range -100 to 100." + }), + description: z.string().optional(), + external_link: z.string().regex(uriRegex).max(255), + price: z.preprocess((value) => { + // If value is already a number, return it + if (typeof value === 'number') return value; + // If it's a string, attempt to parse it + if (typeof value === 'string') return parseFloat(value); + }, z.number().int().min(-2147483648).max(2147483647)), + negotiable: z.boolean().optional(), + start_date: z.date(), + end_date: z.date(), + expires_at: z.date(), + images: z.array(z.instanceof(File)) // An array of File instances + .refine((files) => files.length <= MAX_FILES, { message: `You can upload a maximum of ${MAX_FILES} images.` }) // Limit the number of files + .refine((files) => files.every(file => file.size <= MAX_UPLOAD_SIZE), { message: `Each file must be less than ${MAX_UPLOAD_SIZE / (1024 * 1024)}MB.` }) // Check size for each file + .refine((files) => files.every(file => ACCEPTED_FILE_TYPES.includes(file.type)), { message: 'Only JPEG and PNG files are accepted.' }), // Check type for each file +}); + +function getImageData(event: ChangeEvent) { + // FileList is immutable, so we need to create a new one + const dataTransfer = new DataTransfer(); + + // Add newly uploaded images + Array.from(event.target.files!).forEach((image) => + dataTransfer.items.add(image) + ); + + const files = dataTransfer.files; + const displayUrl = URL.createObjectURL(event.target.files![0]); + + return { files, displayUrl }; +} + +interface PropertyFormProps { + onNewProperty: any; + children: React.ReactNode; +} + +const PropertyForm = ({ onNewProperty, children }: PropertyFormProps) => { + + //const { toast } = useToast(); + const [amenities, setAmenities] = useState([]); + const [preview, setPreview] = useState(""); + + /*useEffect(() => { + fetchAmenities() + .then((data) => { + setAmenities(data); + }) + .catch((error) => { + console.error("Error fetching properties:", error); + }); + }, []);*/ + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + amenities: [], + title: "", + address: "", + beds: 0, + baths: 0, + description: "", + external_link: "", + price: 0, + negotiable: false, + start_date: undefined, + end_date: undefined, + expires_at: undefined, + images: [], + }, + }) + + function onSubmit(values: z.infer) { + // Assuming values contains an 'images' property along with other properties + const { images, ...rest } = values; + + console.log(images); + + // Now, 'images' is a separate variable containing the images array + // and 'rest' contains the rest of the properties from 'values' + + const property = { + ...rest, // Spread the rest of the properties here + start_date: format(rest.start_date, "yyyy-MM-dd") as string, + end_date: format(rest.end_date, "yyyy-MM-dd") as string, + expires_at: format(rest.expires_at, "yyyy-MM-dd'T'HH:mm:ssxxx") as string, + baths: rest.baths!.toString(), + }; + + console.log(JSON.stringify(property)); + + createProperty(property) + .then((data) => { + const subletId = data.id; + console.log(subletId); + + const imageUploadPromises = images.reduce((promiseChain, image) => { + return promiseChain.then(() => createPropertyImage(subletId, image) + .then((data) => { + console.log("return: " + data); + }) + .catch((error) => { + console.error('An error occurred during image upload:', error); + }) + ); + }, Promise.resolve()); + imageUploadPromises + .then(() => { + console.log('All images have been uploaded successfully.'); + return subletId; + }) + .catch((error) => { + console.error('An error occurred during image upload:', error); + }); + }) + .then((subletId) => { + // Images have been uploaded, now fetch properties + fetchProperties() + .then((data) => { + onNewProperty(data); + }) + .catch((error) => { + console.error("Error fetching properties:", error); + }); + }) + .catch((error) => { + console.error("Error in property creation or image upload:", error); + }); + } + + return ( +
+ + + {children} + + + + New Listing + + Create a new property listing below. Make sure to fill in all required fields. + + +
+ + ( + <> + + +
+ + {preview ? + Preview + : + + + + } + + + { + const { files, displayUrl } = getImageData(event) + setPreview(displayUrl); + onChange([...value, ...Array.from(files).slice(0, 6 - value.length)]); + }} + multiple + /> +
+
+ + Select up to 6 images. + + +
+ + )} + /> + ( + + Name + + + + + + )} + /> + ( + + Address + + + + + + )} + /> + ( + + Price + + + + + + )} + /> + ( + +
+ + + + + Negotiable? + +
+ +
+ )} + /> + ( + + Link + + + + + + )} + /> + +
+ ( + + Beds + + + + + {/**/} + + )} + /> + + ( + + Baths + + + + + {/**/} + + )} + /> +
+ + ( + + Start Date + + + + + + + + + date < new Date() || date > form.getValues("end_date")! + } + initialFocus + /> + + + + + )} + /> + + ( + + End Date + + + + + + + + + (form.getValues("start_date") ? date < new Date(form.getValues("start_date")) : date < new Date()) || date < form.getValues("expires_at") + } + initialFocus + /> + + + + + )} + /> + + ( + + Expires At + + + + + + + + + date < new Date() || date > form.getValues("end_date") + } + initialFocus + /> + + + + + )} + /> + + ( + + Amenities + + + {amenities.map((amenity, id) => ( + { + const currentAmenities = field.value || []; + const updatedAmenities = currentAmenities.includes(amenity) + ? currentAmenities.filter(a => a !== amenity) // Remove the amenity if it was selected + : [...currentAmenities, amenity]; // Add the amenity if it was not selected + + field.onChange(updatedAmenities); // Update the form field's value + }} + > + + + ))} + + + + + )} + /> + ( + + Description + +