Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frontend, backend: notifications basic implementation #130

Merged
merged 11 commits into from
Dec 11, 2023
3 changes: 0 additions & 3 deletions frontend/src/ApplicationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ const updateJobStatus = async (jobId: string) => {
};

const ApplicationModal: React.FC<ApplicationModalProps> = ({ isOpen, onClose, applications }) => {
const [acceptedApplications, setAcceptedApplications] = useState<string[]>([]);
const [selectedApplicationId, setSelectedApplicationId] = useState<string | null>(null);

const toggleApplicationDetails = (applicationId: string) => {
Expand All @@ -68,8 +67,6 @@ const ApplicationModal: React.FC<ApplicationModalProps> = ({ isOpen, onClose, ap
});

if (response.status === 200) {
//console.log(`Application with ID ${applicationId} accepted successfully.`);
setAcceptedApplications((prevAccepted) => [...prevAccepted, applicationId]);
updateJobStatus(jobId);
toast.success(
`Application accepted for user: ${applications.find((app) => app.id === applicationId)
Expand Down
13 changes: 4 additions & 9 deletions frontend/src/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Dashboard from "./Dashboard";
import FurBabyLogo from "./FurbabyLogo";
import JobPage from "./Jobs";
import Locations from "./Locations";
import NotificationsView from "./Notifications";
import PetProfiles from "./PetProfiles";
import Profile from "./Profile";
import Settings from "./Settings";
Expand Down Expand Up @@ -196,15 +197,9 @@ const Home = (props: React.PropsWithChildren<HomeProps>) => {
</div>
<div className="hidden md:block">
<div className="ml-4 flex items-center md:ml-6">
<button
type="button"
className="relative rounded-full p-1 text-gray-400 hover:text-black focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
>
<span className="absolute -inset-1.5" />
<span className="sr-only">View notifications</span>
<BellIcon className="h-6 w-6" aria-hidden="true" />
</button>

<NotificationsView
currentUserId={props.authContext.authenticationState.sessionInformation.id}
/>
{/* Profile dropdown */}
<Menu as="div" className="relative ml-3">
<div>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/Jobs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,9 @@ const JobPage: React.FC<JobPageProps> = () => {
.get(API_ROUTES.USER.LOCATION)
.then((response) => {
setLocations(response?.data ?? []);
const default_location = response.data.filter((location: Location) => location.default_location);
const default_location = response.data.filter(
(location: Location) => location.default_location
);
if (default_location.length > 0) {
setJobFormData({ ...jobFormData, location: default_location[0].id });
}
Expand Down
49 changes: 25 additions & 24 deletions frontend/src/Locations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,9 @@ const Locations = () => {
const updateDefault = (location: FurbabyLocation, newDefault: boolean) => {
axios
.put(API_ROUTES.USER.LOCATION, { ...location, default_location: newDefault })
.then((resp) => {
console.log(resp);
.then(() => {
toast.success(`updated default location to ${location.address}`);
getLocations();
})
.catch((err) => {
console.error(err);
Expand All @@ -99,34 +100,37 @@ const Locations = () => {
if (locations.length) {
return (
<div className="grid gap-x-8 gap-y-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
{locations.map((loc, index) => (
{locations.map((loc) => (
<div className="card w-96 bg-base-100 shadow-md" key={loc.id}>
{loc.default_location && (
<div className="absolute top-7 right-7">
<div className="badge badge-outline">
Default
</div>
<div className="badge badge-outline">Default</div>
</div>
)}
<div className="card-body">
<h2 className="card-title">Location {index + 1}</h2>
<p className="prose">{loc.address}</p>
<h2 className="card-title">{loc.address}</h2>
<p className="prose">
{loc.city}, {loc.country} - {loc.zipcode}
</p>
<div className="card-actions justify-between items-center mt-4">
<button className="px-3 py-2 text-sm font-medium text-center text-white bg-blue-400 rounded-lg hover:bg-blue-600 focus:outline-none transition ease-in-out duration-150"
onClick={() => onClickEdit(loc)}>
<button
className="px-3 py-2 text-sm font-medium text-center text-white bg-blue-400 rounded-lg hover:bg-blue-600 focus:outline-none transition ease-in-out duration-150"
onClick={() => onClickEdit(loc)}
>
Edit
</button>
{loc.default_location ? (
<button className="px-3 py-2 text-sm font-medium text-center text-white bg-red-300 rounded-lg hover:bg-red-400 focus:outline-none transition ease-in-out duration-150"
onClick={() => updateDefault(loc, false)}>
<button
className="px-3 py-2 text-sm font-medium text-center text-white bg-red-300 rounded-lg hover:bg-red-400 focus:outline-none transition ease-in-out duration-150"
onClick={() => updateDefault(loc, false)}
>
Remove Default
</button>
) : (
<button className="px-3 py-2 text-sm font-medium text-center text-white bg-green-400 rounded-lg hover:bg-green-600 focus:outline-none transition ease-in-out duration-150"
onClick={() => updateDefault(loc, true)}>
<button
className="px-3 py-2 text-sm font-medium text-center text-white bg-green-400 rounded-lg hover:bg-green-600 focus:outline-none transition ease-in-out duration-150"
onClick={() => updateDefault(loc, true)}
>
Set as default
</button>
)}
Expand All @@ -149,16 +153,13 @@ const Locations = () => {
const onClickEditConfirm = () => {
console.log(editLocationId);
axios
.put(
API_ROUTES.USER.LOCATION,
{
id: editLocationId,
address,
city,
country,
zipcode,
}
)
.put(API_ROUTES.USER.LOCATION, {
id: editLocationId,
address,
city,
country,
zipcode,
})
.then((response) => {
// TODO: handle response
if (response.status === 200) {
Expand Down
141 changes: 141 additions & 0 deletions frontend/src/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Popover, Transition } from "@headlessui/react";
import { BellIcon, NoSymbolIcon, SquaresPlusIcon } from "@heroicons/react/24/outline";
import axios from "axios";
import { Fragment, useEffect, useState } from "react";

import { API_ROUTES } from "./constants";
import useInterval from "./hooks/useInterval";
import { classNames } from "./utils";

type Notification = {
id: string;
created_at: string;
updated_at: string;
data: NotificationData;
};

type NotificationData = {
job_id: string;
owner_id: string;
sitter_id: string;
content: {
title: string;
message: string;
};
};

type NotificationProps = {
currentUserId: string;
};

const NotificationsView = ({ currentUserId }: NotificationProps) => {
const [currentNotifications, setCurrentNotifications] = useState<Notification[]>([]);

useInterval(() => {
axios
.get(API_ROUTES.NOTIFICATIONS)
.then((response) => {
if (response.status === 200) {
if (response.data?.data?.["notifications"].length) {
const filteredNotifications = response.data?.data?.["notifications"].reduce(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(acc: Notification[], notif: any) => {
const notifContent = JSON.parse(
notif.data as unknown as string
) as NotificationData;
if (notifContent.sitter_id === currentUserId) {
return [...acc, { ...notif, data: notifContent }];
}
return acc;
},
[] as Notification[]
);
console.log(filteredNotifications);
setCurrentNotifications(filteredNotifications);
}
}
})
.catch((err) => {
console.error("failed to the latest notifications", err);
});
}, 3000);

useEffect(() => {
if (currentNotifications.length) {
console.log(currentNotifications);
}
}, [currentNotifications.length]);

return (
<>
<Popover className="relative">
<Popover.Button className="inline-flex items-center gap-x-1 text-sm font-semibold leading-6 text-gray-900 outline-none">
<BellIcon
className={classNames(
"h-7 w-7 mt-2 rounded-full",
currentNotifications.length ? "bg-red-400 text-white" : ""
)}
aria-hidden="true"
/>
</Popover.Button>

<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-1/2 z-10 mt-5 flex w-screen max-w-max -translate-x-3/4 px-4">
<div className="w-screen max-w-md flex-auto overflow-hidden rounded-3xl bg-white text-sm leading-6 shadow-lg ring-1 ring-gray-900/5">
<div className="p-4">
{!currentNotifications.length ? (
<div className="group relative flex gap-x-6 rounded-lg p-4 hover:bg-gray-50">
<div className="mt-1 flex h-11 w-11 flex-none items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white">
<NoSymbolIcon
className="h-6 w-6 text-gray-600 group-hover:text-indigo-600"
aria-hidden="true"
/>
</div>
<div>
<span className="font-semibold text-gray-900">
No New Notifications
<span className="absolute inset-0" />
</span>
<p className="mt-1 text-gray-600 prose">Check again after a little while</p>
</div>
</div>
) : (
currentNotifications.map((item) => (
<div
key={item.id}
className="group relative flex gap-x-6 rounded-lg p-4 hover:bg-gray-50"
>
<div className="mt-1 flex h-11 w-11 flex-none items-center justify-center rounded-lg bg-gray-50 group-hover:bg-white">
<SquaresPlusIcon
className="h-6 w-6 text-gray-600 group-hover:text-indigo-600"
aria-hidden="true"
/>
</div>
<div>
<span className="font-semibold text-gray-900">
{item.data.content.title}
<span className="absolute inset-0" />
</span>
<p className="mt-1 text-gray-600 prose">{item.data.content.message}</p>
</div>
</div>
))
)}
</div>
</div>
</Popover.Panel>
</Transition>
</Popover>
</>
);
};

export default NotificationsView;
1 change: 1 addition & 0 deletions frontend/src/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,5 @@ export const API_ROUTES = {
PETS: "pets/",
JOBS: "jobs/",
APPLY: "applications/",
NOTIFICATIONS: "notifications/",
} as const;
25 changes: 25 additions & 0 deletions frontend/src/hooks/useInterval.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect, useRef } from "react";

type Callback = () => void;

function useInterval(callback: Callback, delay: number) {
const savedCallback = useRef<Callback | undefined>();

useEffect(() => {
savedCallback.current = callback;
});

useEffect(() => {
function tick() {
if (savedCallback.current) {
savedCallback.current();
}
}

const id = setInterval(tick, delay);

return () => clearInterval(id);
}, [delay]);
}

export default useInterval;
27 changes: 27 additions & 0 deletions furbaby/api/migrations/0016_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 4.0 on 2023-12-11 09:05

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):
dependencies = [
("api", "0015_alter_applications_status"),
]

operations = [
migrations.CreateModel(
name="Notifications",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("data", models.JSONField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
),
]
7 changes: 7 additions & 0 deletions furbaby/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,10 @@ class Meta:
constraints = [
models.UniqueConstraint(fields=("user_id", "job_id"), name="user_id_job_id_constraint")
]


class Notifications(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
data = models.JSONField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
13 changes: 12 additions & 1 deletion furbaby/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from rest_framework import serializers
from .models import Users, Locations, Pets, Jobs, Applications
from .models import Notifications, Users, Locations, Pets, Jobs, Applications
from django.contrib.auth.hashers import make_password
from django.core.exceptions import ValidationError

Expand Down Expand Up @@ -148,3 +148,14 @@ def to_representation(self, instance):
user_representation = UserSerializer(instance.user).data
representation["user"] = user_representation
return representation


class NotificationsSerializer(serializers.Serializer):
data = serializers.CharField(max_length=1200) # type: ignore

class Meta:
model = Notifications
fields = ["data"]

def create(self, data):
return Notifications.objects.create(**data)
2 changes: 2 additions & 0 deletions furbaby/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
PetRetrieveUpdateDeleteView,
JobView,
ApplicationView,
notifications_view,
)

# NOTE: We might have to use the decorator csrf_protect to ensure that
Expand Down Expand Up @@ -48,4 +49,5 @@
),
path("jobs/", JobView.as_view(), name="custom-job-view"),
path("applications/", ApplicationView.as_view(), name="application-list"),
path("notifications/", notifications_view, name="notifications-view"),
]
Loading
Loading