diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..901336d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+PORT=5000
+REACT_APP_API_BASE_URL=http://localhost:3000
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 4d29575..017ccd2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@
# misc
.DS_Store
+.env
.env.local
.env.development.local
.env.test.local
@@ -21,3 +22,5 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+
+.idea
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index b4446c4..8e312ed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,12 +15,15 @@
"@types/node": "^16.18.119",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-hook-form": "^7.53.1",
"react-router-dom": "^6.27.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
- "web-vitals": "^2.1.4"
+ "web-vitals": "^2.1.4",
+ "zustand": "^5.0.1"
},
"devDependencies": {
"tailwindcss": "^3.4.14"
@@ -11257,6 +11260,14 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "0.454.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz",
+ "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -13906,6 +13917,22 @@
"integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==",
"license": "MIT"
},
+ "node_modules/react-hook-form": {
+ "version": "7.53.1",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.1.tgz",
+ "integrity": "sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -17472,6 +17499,35 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.1.tgz",
+ "integrity": "sha512-pRET7Lao2z+n5R/HduXMio35TncTlSW68WsYBq2Lg1ASspsNGjpwLAsij3RpouyV6+kHMwwwzP0bZPD70/Jx/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index b9c508a..e849e26 100644
--- a/package.json
+++ b/package.json
@@ -10,12 +10,15 @@
"@types/node": "^16.18.119",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-hook-form": "^7.53.1",
"react-router-dom": "^6.27.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
- "web-vitals": "^2.1.4"
+ "web-vitals": "^2.1.4",
+ "zustand": "^5.0.1"
},
"scripts": {
"start": "react-scripts start",
diff --git a/src/App.tsx b/src/App.tsx
index c5f57e7..7f2c7c9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,10 +1,14 @@
import { Outlet } from "react-router-dom";
+
import Navigation from "./components/navigation";
+import Notifications from "./components/notifications/notifications.component";
+
export default function App() {
return (
-
-
+
+
+
);
}
diff --git a/src/adapters/message.adapter.ts b/src/adapters/message.adapter.ts
new file mode 100644
index 0000000..6799064
--- /dev/null
+++ b/src/adapters/message.adapter.ts
@@ -0,0 +1,6 @@
+import Message from "../types/message";
+
+export interface MessageAdapter {
+ sendMessage: (message: Message) => Promise
;
+ fetchMessages: (receiverId: string) => Promise;
+}
\ No newline at end of file
diff --git a/src/assets/images/block-cursor.png b/src/assets/images/block-cursor.png
new file mode 100644
index 0000000..e51225d
Binary files /dev/null and b/src/assets/images/block-cursor.png differ
diff --git a/src/assets/images/normal-cursor.png b/src/assets/images/normal-cursor.png
new file mode 100644
index 0000000..f9a2f7a
Binary files /dev/null and b/src/assets/images/normal-cursor.png differ
diff --git a/src/assets/images/pointer-cursor.png b/src/assets/images/pointer-cursor.png
new file mode 100644
index 0000000..5dd671f
Binary files /dev/null and b/src/assets/images/pointer-cursor.png differ
diff --git a/src/assets/images/text-cursor.png b/src/assets/images/text-cursor.png
new file mode 100644
index 0000000..c2122b7
Binary files /dev/null and b/src/assets/images/text-cursor.png differ
diff --git a/src/common/error.common.ts b/src/common/error.common.ts
new file mode 100644
index 0000000..0a8ce42
--- /dev/null
+++ b/src/common/error.common.ts
@@ -0,0 +1,23 @@
+export class CommonError extends Error {
+ public statusCode: number;
+ public errorCode: string;
+ public details?: any;
+
+ constructor(statusCode: number, errorCode: string, message: string, details?: any) {
+ super(message);
+ this.statusCode = statusCode;
+ this.errorCode = errorCode;
+ this.details = details;
+
+ Object.setPrototypeOf(this, CommonError.prototype);
+ }
+
+ toJSON() {
+ return {
+ statusCode: this.statusCode,
+ errorCode: this.errorCode,
+ message: this.message,
+ details: this.details || null,
+ };
+ }
+}
diff --git a/src/components/add-friend.component.tsx b/src/components/add-friend.component.tsx
new file mode 100644
index 0000000..bd8d3b9
--- /dev/null
+++ b/src/components/add-friend.component.tsx
@@ -0,0 +1,100 @@
+import { useUserStore } from "../stores/user.store";
+import { SubmitHandler, useForm } from "react-hook-form";
+import { sendFriendRequest } from "../services/friend-request.service";
+import ShareButton from "./send-invite-link.component";
+import { useState } from "react";
+import Button from "./buttons/button";
+
+type FormInputs = {
+ content: string;
+}
+
+export default function AddFriend() {
+ const user = useUserStore();
+ const [error, setError] = useState(null);
+ const { register, handleSubmit, reset } = useForm({
+ defaultValues: {
+ content: ""
+ }
+ });
+
+ const onSubmit: SubmitHandler = async (input) => {
+ try {
+ await sendFriendRequest(input.content);
+ reset();
+ } catch (err) {
+ console.error(err);
+ setError((err as Error).message);
+ }
+ }
+
+ const sendFriendRequest = async (receiverId: string) => {
+ try {
+ const randomuuid = crypto.randomUUID();
+ const response = await fetch(`${process.env.REACT_APP_API_BASE_URL}/social/friend-request/${randomuuid}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({ receiverId: receiverId }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message);
+ }
+ } catch (error) {
+ console.error('Error sending friend request:', error);
+ throw error;
+ }
+ }
+
+ return (
+
+
+
+
+
☺☻
+
Adress Book
+
+
+
This console's Wii number
+
+
+
+
+
+
+
+
+
+
+
+ {error &&
{error}
}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/buttons/button.tsx b/src/components/buttons/button.tsx
new file mode 100644
index 0000000..0d98284
--- /dev/null
+++ b/src/components/buttons/button.tsx
@@ -0,0 +1,28 @@
+interface ButtonProps {
+ label: string;
+ onClick?: (params?: any) => any;
+ type?: "submit" | "button";
+ disabled?: boolean;
+ variant?: "primary" | "secondary" | "tertiary";
+ className?: string;
+}
+
+export default function Button({
+ label,
+ onClick,
+ type = "button",
+ disabled,
+ variant = "primary",
+ className,
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/buttons/icon.button.tsx b/src/components/buttons/icon.button.tsx
new file mode 100644
index 0000000..b83d2e4
--- /dev/null
+++ b/src/components/buttons/icon.button.tsx
@@ -0,0 +1,17 @@
+interface IconButtonProps {
+ children?: React.ReactNode;
+ onClick: (params?: any) => any;
+ className?: string;
+}
+
+export default function IconButton({
+ children,
+ onClick,
+ className,
+}: IconButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/cards/friend-placeholder.card.css b/src/components/cards/friend-placeholder.card.css
new file mode 100644
index 0000000..eddc4f4
--- /dev/null
+++ b/src/components/cards/friend-placeholder.card.css
@@ -0,0 +1,9 @@
+.friend-card-placeholder {
+ @apply border-4 border-zinc-400 aspect-video rounded-[5%/40%] bg-slate-50 shadow-[inset_0px_-64px_32px_rgba(0,0,0,0.2)] cursor-not-allowed;
+ @apply bg-gradient-to-b from-zinc-300 via-zinc-300 to-zinc-300;
+ @apply bg-[length:7px_7px];
+}
+
+.friend-card-placeholder:hover {
+ cursor: url("../../assets/images/block-cursor.png"), not-allowed;
+}
diff --git a/src/components/cards/friend-placeholder.card.tsx b/src/components/cards/friend-placeholder.card.tsx
new file mode 100644
index 0000000..8668a6f
--- /dev/null
+++ b/src/components/cards/friend-placeholder.card.tsx
@@ -0,0 +1,9 @@
+import "./friend-placeholder.card.css";
+
+export default function FriendCardPlaceholder() {
+ return (
+
+ Wii Chat
+
+ );
+}
diff --git a/src/components/cards/friend.card.style.css b/src/components/cards/friend.card.style.css
new file mode 100644
index 0000000..70a4633
--- /dev/null
+++ b/src/components/cards/friend.card.style.css
@@ -0,0 +1,32 @@
+@layer {
+ .friend-card {
+ @apply flex justify-center items-center font-bold text-2xl uppercase;
+ @apply cursor-pointer border-4 border-zinc-400 aspect-video rounded-[5%/40%] bg-slate-50 shadow-[inset_0px_-64px_32px_rgba(0,0,0,0.2)] hover:scale-105 transition-all duration-300;
+ @apply bg-[length:7px_7px];
+ }
+
+ .friend-card:hover {
+ cursor: url("../../assets/images/pointer-cursor.png"), not-allowed;
+ }
+}
+
+.friend-card-red {
+ @apply friend-card;
+ @apply text-red-400;
+ @apply border-4 border-red-300 bg-red-50 shadow-[inset_0px_-64px_32px_rgba(255,0,0,0.2)];
+ @apply bg-gradient-to-b from-red-100 via-red-200 to-red-200;
+}
+
+.friend-card-blue {
+ @apply friend-card;
+ @apply text-blue-500;
+ @apply border-4 border-blue-300 bg-blue-50 shadow-[inset_0px_-64px_32px_rgba(0,0,255,0.2)];
+ @apply bg-gradient-to-b from-blue-200 via-blue-200 to-blue-200;
+}
+
+.friend-card-green {
+ @apply friend-card;
+ @apply text-green-500;
+ @apply border-4 border-green-300 bg-green-50 shadow-[inset_0px_-64px_32px_rgba(0,255,0,0.2)];
+ @apply bg-gradient-to-b from-green-200 via-green-200 to-green-200;
+}
diff --git a/src/components/cards/friend.card.tsx b/src/components/cards/friend.card.tsx
new file mode 100644
index 0000000..a279de5
--- /dev/null
+++ b/src/components/cards/friend.card.tsx
@@ -0,0 +1,24 @@
+import { Friend } from "../../types/friend";
+import "./friend.card.style.css";
+
+interface FriendCardProps {
+ friend: Friend;
+}
+
+export default function FriendCard({ friend }: FriendCardProps) {
+ const cardClasses = [
+ "friend-card-red",
+ "friend-card-blue",
+ "friend-card-green",
+ ];
+
+ const getRandomCardClass = () => {
+ return cardClasses[Math.floor(Math.random() * cardClasses.length)];
+ };
+
+ return (
+
+ {friend.username}
+
+ );
+}
diff --git a/src/components/cards/message.card.tsx b/src/components/cards/message.card.tsx
new file mode 100644
index 0000000..23213fe
--- /dev/null
+++ b/src/components/cards/message.card.tsx
@@ -0,0 +1,74 @@
+import Message from "../../types/message";
+import { dateFormater } from "../../utils/dateFormater";
+
+interface MessageCardProps {
+ message: Message;
+ isSender?: boolean;
+ error?: boolean;
+ handleError?: () => void;
+}
+
+export default function MessageCard({
+ message,
+ isSender = true,
+ error = false,
+ handleError,
+}: MessageCardProps) {
+ return (
+
+
+
+
+
+ {error ? (
+
+ Error{" "}
+
+ resend message
+ {" "}
+
+ ) : (
+ dateFormater(message.sendAt)
+ )}
+
+
+ );
+}
+
+interface MessageTextProps {
+ message: string;
+}
+function MessageText({ message }: MessageTextProps) {
+ return (
+
+ {message.split(" ").map((word, index) => {
+ if (word.startsWith("http://") || word.startsWith("https://")) {
+ return (
+
+ {word + " "}
+
+ );
+ } else {
+ return
{word} ;
+ }
+ })}
+
+ );
+}
diff --git a/src/components/forms/auth.form.tsx b/src/components/forms/auth.form.tsx
new file mode 100644
index 0000000..da6826e
--- /dev/null
+++ b/src/components/forms/auth.form.tsx
@@ -0,0 +1,62 @@
+import { useState } from "react";
+import { useForm, SubmitHandler } from "react-hook-form";
+import { UserDTO } from "../../dtos/user.dto";
+import Button from "../buttons/button";
+
+interface AuthFormProps {
+ submitFn: (data: UserDTO) => Promise;
+ children?: React.ReactNode;
+ label: string;
+}
+
+export default function AuthForm({ submitFn, children, label }: AuthFormProps) {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm();
+
+ const [resError, setResError] = useState();
+
+ const onSubmit: SubmitHandler = async (data) => {
+ try {
+ await submitFn(data);
+ } catch (error: any) {
+ setResError(error.message);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/friend-request-card.component.tsx b/src/components/friend-request-card.component.tsx
new file mode 100644
index 0000000..286eb43
--- /dev/null
+++ b/src/components/friend-request-card.component.tsx
@@ -0,0 +1,55 @@
+import { acceptRequest } from "../services/friend-request.service";
+import { useNotificationStore } from "../stores/notification.store";
+import { FriendRequest } from "../types/friend-request";
+import Notification from "../types/notification";
+import { dateFormater } from "../utils/dateFormater";
+import Button from "./buttons/button";
+
+export default function FriendRequestCard({ request, removeFriendRequest }: { request: FriendRequest, removeFriendRequest: (friendRequest: FriendRequest) => void }) {
+ const { notifications, setNotifications } = useNotificationStore();
+
+
+ async function updateAcceptedRequestsNotifications() {
+ await acceptRequest(request.id.toString());
+
+ const updatedNotifications = notifications.map((notification: Notification) => {
+ if (notification.id === request.id) {
+ return {
+ ...notification,
+ didIAccept: true,
+ isSeen: true,
+ };
+ }
+ return notification;
+ });
+
+ setNotifications(updatedNotifications)
+ removeFriendRequest(request)
+
+ }
+
+ return (
+
+
+
+
+

+
{request.senderId}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/guards/guest-route.guard.tsx b/src/components/guards/guest-route.guard.tsx
new file mode 100644
index 0000000..e899d7a
--- /dev/null
+++ b/src/components/guards/guest-route.guard.tsx
@@ -0,0 +1,19 @@
+import { Outlet, useNavigate } from "react-router-dom";
+import { useEffect } from "react";
+import { checkUserAuth } from "../../services/auth.service";
+
+export default function GuestRoute() {
+ const navigate = useNavigate();
+
+ const checkAuth = async () => {
+ const isAuth = await checkUserAuth();
+ if (isAuth) {
+ navigate("/");
+ }
+ };
+
+ useEffect(() => {
+ checkAuth();
+ }, []);
+ return ;
+}
diff --git a/src/components/guards/procteded-route.guard.tsx b/src/components/guards/procteded-route.guard.tsx
new file mode 100644
index 0000000..ee41ebf
--- /dev/null
+++ b/src/components/guards/procteded-route.guard.tsx
@@ -0,0 +1,37 @@
+import { Outlet, useLoaderData, useNavigate } from "react-router-dom";
+import { useEffect } from "react";
+import { checkUserAuth, getCurrentUser } from "../../services/auth.service";
+import { useUserStore } from "../../stores/user.store";
+import { useFriendStore } from "../../stores/friend.store";
+import { useNotificationStore } from "../../stores/notification.store";
+import Notification from "../../types/notification";
+
+export default function ProtectedRoute() {
+ const navigate = useNavigate();
+ const { id, updateUser, clearUser } = useUserStore();
+ const { clearFriends } = useFriendStore();
+ const { setNotifications } = useNotificationStore();
+
+ useEffect(() => {
+ const checkAuth = async () => {
+ const isAuth = await checkUserAuth();
+ if (!isAuth) {
+ clearUser();
+ clearFriends();
+ navigate("/login");
+ } else if (!id && isAuth) {
+ const currentUser = await getCurrentUser();
+ updateUser(currentUser);
+ }
+ };
+
+ checkAuth();
+ }, [id, navigate, clearUser, clearFriends, updateUser]);
+
+ const initialNotifications = useLoaderData() as Notification[];
+ useEffect(() => {
+ setNotifications(initialNotifications);
+ }, [initialNotifications, setNotifications]);
+
+ return ;
+}
diff --git a/src/components/loaders/messages.loader.tsx b/src/components/loaders/messages.loader.tsx
new file mode 100644
index 0000000..54ae657
--- /dev/null
+++ b/src/components/loaders/messages.loader.tsx
@@ -0,0 +1,8 @@
+import { MessageService } from "../../services/message.service";
+
+export const MessagesLoader = async ({ params }: { params: Record }) => {
+ const messageService = new MessageService();
+ const { receiverId } = params;
+ if (!receiverId) throw new Error("Receiver ID missing");
+ return await messageService.fetchMessages(receiverId);
+};
diff --git a/src/components/loaders/notifications.loader.tsx b/src/components/loaders/notifications.loader.tsx
new file mode 100644
index 0000000..4ac0fc2
--- /dev/null
+++ b/src/components/loaders/notifications.loader.tsx
@@ -0,0 +1,14 @@
+import Notification from "../../types/notification";
+
+export const NotificationsLoader = ({
+ params,
+}: {
+ params: Record;
+}) => {
+ const storedNotifications: string | null =
+ localStorage.getItem("notifications");
+ const parsedNotifications: Notification[] = storedNotifications
+ ? JSON.parse(storedNotifications)
+ : [];
+ return parsedNotifications;
+};
diff --git a/src/components/loaders/spinner/loader.component.tsx b/src/components/loaders/spinner/loader.component.tsx
new file mode 100644
index 0000000..60c5837
--- /dev/null
+++ b/src/components/loaders/spinner/loader.component.tsx
@@ -0,0 +1,9 @@
+import './loader.css';
+
+export default function Loader() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/loaders/spinner/loader.css b/src/components/loaders/spinner/loader.css
new file mode 100644
index 0000000..3cb8dd0
--- /dev/null
+++ b/src/components/loaders/spinner/loader.css
@@ -0,0 +1,16 @@
+.loader {
+ width: 50px;
+ padding: 8px;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ background: #25b09b;
+ --_m:
+ conic-gradient(#0000 10%,#000),
+ linear-gradient(#000 0 0) content-box;
+ -webkit-mask: var(--_m);
+ mask: var(--_m);
+ -webkit-mask-composite: source-out;
+ mask-composite: subtract;
+ animation: l3 1s infinite linear;
+}
+@keyframes l3 {to{transform: rotate(1turn)}}
\ No newline at end of file
diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx
index 1704522..76c06a5 100644
--- a/src/components/navigation.tsx
+++ b/src/components/navigation.tsx
@@ -1,12 +1,78 @@
-import * as React from "react";
-import { Link } from "react-router-dom";
+import { Link, useNavigate } from "react-router-dom";
+import { logoutUser } from "../services/auth.service";
+import { useFriendStore } from "../stores/friend.store";
+import { useUserStore } from "../stores/user.store";
+import { countUnseenNotifications } from "../utils/count-unseen-notifications";
+import { useEffect } from "react";
+import { useNotificationStore } from "../stores/notification.store";
+import IconButton from "./buttons/icon.button";
+
+import { DoorOpen, Users, Mail } from "lucide-react";
export default function Navigation() {
+ const { clearUser } = useUserStore();
+ const { clearFriends } = useFriendStore();
+ const { notifications } = useNotificationStore();
+ const navigate = useNavigate();
+
+ const handleLogout = () => {
+ clearUser();
+ clearFriends();
+ logoutUser();
+ navigate("/login");
+ };
+
+ useEffect(() => {
+ countUnseenNotifications(notifications);
+ }, [notifications]);
+
return (
-
-
Chats
-
Friends
-
Settings
+
+
+
+
navigate("/")}>
+ Chats
+
+
navigate("/friends")}
+ >
+
+
+
+
+ Wii Chat!
+
+
+ navigate("/notifications")}
+ >
+
+
+ {" "}
+ {countUnseenNotifications(notifications)}
+
+
+
+ handleLogout()}>
+
+
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/components/notifications/friend-request-accepted.component.tsx b/src/components/notifications/friend-request-accepted.component.tsx
new file mode 100644
index 0000000..bd41de2
--- /dev/null
+++ b/src/components/notifications/friend-request-accepted.component.tsx
@@ -0,0 +1,39 @@
+import { useEffect } from "react";
+import { countUnseenNotifications } from "../../utils/count-unseen-notifications";
+import Notification from "../../types/notification";
+import { useNotificationStore } from "../../stores/notification.store";
+import { NotificationService, EventName } from "../../services/notification.service";
+
+export default function FriendRequestAccepted({ notificationService }: { notificationService: NotificationService }) {
+
+ const { notifications, addNotification } = useNotificationStore();
+
+ function saveAcceptedRequest(request: any) {
+ const notification: Notification = {
+ id: crypto.randomUUID(),
+ type: "friend-request-accepted",
+ emitterId: request.userId,
+ receivedAt: new Date().toISOString(),
+ status: "my-friend-request-accepted",
+ isSeen: false,
+ }
+
+ addNotification(notification);
+ countUnseenNotifications(notifications);
+ }
+
+
+
+ useEffect(() => {
+ const handleAcceptedFriendRequest = (request: any) => {
+ saveAcceptedRequest(request);
+ }
+ const eventSource = notificationService.eventListener(handleAcceptedFriendRequest, EventName.FRIEND_REQUEST_ACCEPTED);
+
+ return () => {
+ eventSource.close();
+ };
+ }, [])
+
+ return null
+}
\ No newline at end of file
diff --git a/src/components/notifications/friend-request-received.component.tsx b/src/components/notifications/friend-request-received.component.tsx
new file mode 100644
index 0000000..249a936
--- /dev/null
+++ b/src/components/notifications/friend-request-received.component.tsx
@@ -0,0 +1,42 @@
+import { useEffect } from "react";
+import { countUnseenNotifications } from "../../utils/count-unseen-notifications";
+import Notification from "../../types/notification";
+import { useNotificationStore } from "../../stores/notification.store";
+import { NotificationService, EventName } from "../../services/notification.service";
+
+export default function FriendRequestReceived({ notificationService }: { notificationService: NotificationService }) {
+ const { notifications, addNotification } = useNotificationStore();
+
+ function saveReceivedRequest(request: any) {
+ const notification: Notification = {
+ id: request.id,
+ type: "friend-request-received",
+ emitterId: request.senderId,
+ receivedAt: request.requestedAt,
+ didIAccept: false,
+ status: "pending-request",
+ isSeen: false,
+ };
+ addNotification(notification);
+ const existingNotifications = JSON.parse(
+ localStorage.getItem("notifications") || "[]"
+ );
+ const updatedNotifications = [notification, ...existingNotifications];
+ localStorage.setItem("notifications", JSON.stringify(updatedNotifications));
+ countUnseenNotifications(notifications);
+ }
+
+ useEffect(() => {
+
+ const handleNewFriendRequest = (request: any) => {
+ saveReceivedRequest(request);
+ };
+ const eventSource = notificationService.eventListener(handleNewFriendRequest, EventName.FRIEND_REQUEST_RECEIVED);
+
+ return () => {
+ eventSource.close();
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/components/notifications/notifications.component.tsx b/src/components/notifications/notifications.component.tsx
new file mode 100644
index 0000000..017af47
--- /dev/null
+++ b/src/components/notifications/notifications.component.tsx
@@ -0,0 +1,16 @@
+import FriendRequestReceived from "./friend-request-accepted.component";
+import FriendRequestAccepted from "./friend-request-received.component";
+import { NotificationService } from "../../services/notification.service";
+import { useNotificationStore } from "../../stores/notification.store";
+
+export default function Notifications() {
+
+ const { service } = useNotificationStore();
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/send-invite-link.component.tsx b/src/components/send-invite-link.component.tsx
new file mode 100644
index 0000000..94e9035
--- /dev/null
+++ b/src/components/send-invite-link.component.tsx
@@ -0,0 +1,35 @@
+import { Share } from 'lucide-react';
+import { useUserStore } from '../stores/user.store';
+import Button from './buttons/button';
+
+const ShareButton = ({ title = 'Wii chat friend request', text = 'Hey I want to be your friend on Wii chat' }) => {
+ const user = useUserStore();
+
+ const handleShare = async () => {
+
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ title,
+ text,
+ url: 'invite-link/' + user.id
+ });
+ } catch (err) {
+
+ }
+ } else {
+ alert('Web Share API is not supported in your browser');
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default ShareButton;
\ No newline at end of file
diff --git a/src/dtos/user.dto.ts b/src/dtos/user.dto.ts
new file mode 100644
index 0000000..9999bca
--- /dev/null
+++ b/src/dtos/user.dto.ts
@@ -0,0 +1,4 @@
+export interface UserDTO {
+ username: string;
+ password: string;
+}
diff --git a/src/errors/bad-request.error.ts b/src/errors/bad-request.error.ts
new file mode 100644
index 0000000..51647ba
--- /dev/null
+++ b/src/errors/bad-request.error.ts
@@ -0,0 +1,8 @@
+import { CommonError } from "../common/error.common";
+
+export class BadRequestError extends CommonError {
+ constructor(message: string) {
+ super(400, 'BAD_REQUEST', message);
+ Object.setPrototypeOf(this, BadRequestError.prototype);
+ }
+}
diff --git a/src/index.css b/src/index.css
index bd6213e..c21b8d1 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,3 +1,337 @@
@tailwind base;
@tailwind components;
-@tailwind utilities;
\ No newline at end of file
+@tailwind utilities;
+
+@layer components {
+ .btn {
+ @apply relative md:px-8 md:py-1 px-2 rounded-full overflow-hidden transition-all duration-300 outline-none font-semibold;
+ cursor: url("./assets/images/pointer-cursor.png"), auto;
+ }
+
+ .btn:hover,
+ .btn:focus {
+ @apply scale-105;
+ }
+
+ .btn:active {
+ @apply scale-95;
+ }
+ .primary-btn {
+ @apply btn border-2 border-cyan-400;
+ }
+
+ .primary-btn::before {
+ @apply absolute content-[""] w-full h-full top-0 left-0 bg-slate-200 rounded-full z-[-2] shadow-[inset_0px_-4px_8px_4px_rgba(0,0,0,0.1)];
+ }
+
+ .primary-btn::after {
+ @apply absolute content-[""] top-[-4px] w-[90%] h-[60%] bg-white left-1/2 transform -translate-x-1/2 rounded-full z-[-1];
+ }
+
+ .primary-btn:hover,
+ .primary-btn:focus {
+ @apply shadow-lg shadow-cyan-100;
+ }
+
+ .primary-btn:active {
+ @apply shadow-none;
+ }
+
+ .primary-btn:disabled {
+ @apply cursor-not-allowed opacity-50 border-slate-500 pointer-events-none;
+ }
+
+ .secondary-btn {
+ @apply btn bg-white rounded-full font-semibold shadow-[inset_0_-5px_8px_4px_rgba(0,0,0,0.1),0_0px_4px_1px_rgba(0,0,0,0.2),inset_0px_-3px_0px_rgba(255,255,255,1)] transition-all duration-300;
+ }
+
+ .icon-btn {
+ @apply btn w-fit aspect-square h-fit border-2 border-cyan-400 font-bold text-2xl text-slate-400 shadow-lg flex justify-center items-center;
+ }
+
+ .icon-btn::before {
+ @apply absolute content-[""] w-full h-full top-0 left-0 bg-slate-200 rounded-full z-[-2] shadow-[inset_0px_-8px_8px_4px_rgba(0,0,0,0.2)];
+ }
+
+ .icon-btn::after {
+ @apply absolute content-[""] top-[-4px] w-[90%] h-[60%] bg-slate-100 left-1/2 transform -translate-x-1/2 rounded-full z-[-1];
+ }
+
+ .tertiary-btn {
+ @apply btn;
+ }
+
+ .input-group {
+ @apply flex flex-col gap-1;
+ }
+
+ .input-group {
+ @apply font-semibold text-slate-500;
+ }
+ .input-group input {
+ @apply px-4 py-1 rounded-lg border-2 shadow-[inset_0px_-8px_8px_0px_rgba(0,0,0,0.2)];
+ }
+
+ .input-group input:focus {
+ @apply outline-2 outline-cyan-400 ring-[3px] ring-cyan-500;
+ }
+}
+body {
+ /* background-image: linear-gradient(
+ 0deg,
+ #ededed 41.67%,
+ #ffffff 41.67%,
+ #ffffff 50%,
+ #ededed 50%,
+ #ededed 91.67%,
+ #ffffff 91.67%,
+ #ffffff 100%
+ );
+ background-size: 24px 24px; */
+ @apply bg-slate-100 text-slate-600 flex w-full h-lvh border-2;
+ cursor: url("./assets/images/normal-cursor.png"), auto;
+}
+
+input:hover,
+input:focus,
+input:active {
+ cursor: url("./assets/images/text-cursor.png"), text;
+}
+
+#root {
+ @apply h-full w-full flex flex-col items-center justify-center;
+}
+
+h1 {
+ @apply text-5xl font-bold sm:text-6xl;
+}
+
+main {
+ @apply max-w-[1440px] w-full h-full m-auto;
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: #f1f1f1;
+}
+
+::-webkit-scrollbar-thumb {
+ @apply bg-slate-200;
+ border-radius: 999px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ @apply bg-slate-300;
+}
+
+
+/* Friend page */
+/* Footer */
+.wii-footer{
+ background-color: #D3D5DB;
+ z-index: 0;
+ height: 25vh;
+ width: 100vw;
+ position: fixed;
+ bottom: 0;
+ border-top: solid cyan;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+
+ box-shadow: inset 0em 2em 30px rgba(128, 128, 128, 0.529);
+
+}
+
+.footer-notch{
+ position: relative;
+ z-index: 10;
+ top: -3px;
+ width: 30%;
+ height: 40%;
+ background-color: white;
+ border-bottom-left-radius: 100px;
+ border-bottom-right-radius: 100px;
+ border-bottom: solid cyan;
+ border-left: solid cyan;
+ border-right: solid cyan;
+ box-shadow: 0em 10px 20px -10px rgba(128, 128, 128, 0.568);
+}
+
+
+/* .footer-notch-cover{
+ position: relative;
+ z-index: 10;
+ width: 30%;
+ height: 50px;
+ background-color: white;
+ border-bottom-left-radius: 100px;
+ border-bottom-right-radius: 100px;
+ border-bottom: solid cyan;
+ border-left: solid cyan;
+ border-right: solid cyan;
+} */
+
+body
+{
+ background-color: rgb(241, 241, 241);
+}
+
+/* Adress book */
+.adress-book-rim
+{
+ width: 40vw;
+ height: 50vh;
+ box-shadow: 2px 2px 1px rgb(124, 238, 18);
+ background-color: aliceblue;
+
+ min-width: 400px;
+
+ border-radius: 10px;
+
+ box-shadow: -10px 10px 0px rgb(150, 179, 180, 0.952);
+ margin: auto;
+
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+.adress-book{
+ color: #63C5F7;
+ font-size: 2rem;
+ box-shadow: -3px 3px 2px inset rgba(150, 179, 180, 0.952);
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+
+ width: 98%;
+ height: 95%;
+ margin: auto;
+
+ border-radius: 10px;
+ background-color: aliceblue;
+
+ min-width: 400px;
+}
+
+.adress-book footer
+{
+ background-color: gainsboro;
+ width: 99%;
+ position: relative;
+ bottom: 0;
+}
+
+.smiley
+{
+ font-size: 5rem;
+}
+
+.friend-code{
+ color: grey;
+ margin-top: 2rem;
+}
+
+.friend-code .info-message{
+ color: rgb(187, 187, 187);
+ font-size: 1.8rem;
+}
+
+.clipboard-logo img{
+ width: 60%;
+}
+
+/* Friend Form */
+.add-friend-form{
+ background-color: aliceblue;
+ width: 40vw;
+ margin: auto;
+ margin-top: 2rem;
+ min-width: 400px;
+
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+
+ border: solid grey;
+ border-radius: 10px;
+
+ -webkit-box-shadow: 0px 0px 0px 10px aliceblue;
+ -moz-box-shadow: 0px 0px 0px 10px aliceblue;
+ box-shadow: 0px 0px 0px 10px aliceblue;
+}
+
+.add-friend-form input{
+ width: 99%;
+ min-width: 350px;
+ text-align: center;
+ padding: 1rem;
+ background-color: aliceblue;
+ border-radius: 10px;
+}
+
+.add-friend-form-header{
+ background-color: rgb(221, 221, 221);
+ color: rgb(68, 68, 68);
+ width: 39.7vw;
+ min-width: 393px;
+ border-top-left-radius: 3px;
+ border-top-right-radius: 3px;
+ text-align: center;
+}
+
+/* Friend request list */
+.friend-request-list{
+ width: 40vw;
+ height: 60vh;
+ min-width: 393px;
+ box-shadow: 2px 2px 1px rgb(124, 238, 18);
+ background-color: aliceblue;
+
+ border-radius: 10px;
+
+ box-shadow: -7px 8px 0px rgba(150, 179, 180, 0.952);
+ margin: auto;
+
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+.friend-request-card{
+ width: 80%;
+ padding-bottom: 0.5rem;
+ margin-bottom: 0.5rem;
+ border-bottom: solid 1px #63C5F7;
+
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.friend-request-card-info{
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.friend-request-card img{
+ width: 4rem;
+ margin-right: 1rem;
+ border-radius: 10px;
+ border: solid 2px rgb(146, 146, 146);
+}
diff --git a/src/index.tsx b/src/index.tsx
index 69d9e6a..4950a36 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,12 +1,22 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
+
import reportWebVitals from "./reportWebVitals";
-import HomePage from "./pages/HomePage";
-import ChatPage from "./pages/ChatPage";
-import FriendPage from "./pages/FriendPage";
import App from "./App";
import "./index.css";
+import RegisterPage from "./pages/register.page";
+import LoginPage from "./pages/login.page";
+import HomePage from "./pages/home.page";
+import ChatListPage from "./pages/chat-list.page";
+import ChatPage from "./pages/chat.page";
+import FriendPage from "./pages/friend.page";
+import ProtectedRoute from "./components/guards/procteded-route.guard";
+import GuestRoute from "./components/guards/guest-route.guard";
+import { MessagesLoader } from "./components/loaders/messages.loader";
+import NotificationListPage from "./pages/notifications.page";
+import { NotificationsLoader } from "./components/loaders/notifications.loader";
+import InviteLinkPage from "./pages/invite-link.page";
const router = createBrowserRouter([
{
@@ -14,16 +24,48 @@ const router = createBrowserRouter([
element: ,
children: [
{
- index: true,
- element: ,
+ element: ,
+ loader: NotificationsLoader,
+ children: [
+ {
+ index: true,
+ element: ,
+ },
+ {
+ path: "/chats",
+ element: ,
+ },
+ {
+ path: "/chats/:receiverId",
+ element: ,
+ loader: MessagesLoader,
+ },
+ {
+ path: "/friends",
+ element: ,
+ },
+ {
+ path: "/invite-link/:senderId",
+ element: ,
+ },
+ {
+ path: "/notifications",
+ element: ,
+ },
+ ],
},
+ ],
+ },
+ {
+ element: ,
+ children: [
{
- path: "/chats",
- element: ,
+ path: "/register",
+ element: ,
},
{
- path: "/friends",
- element: ,
+ path: "/login",
+ element: ,
},
],
},
diff --git a/src/pages/FriendPage.tsx b/src/pages/FriendPage.tsx
deleted file mode 100644
index 1e87d16..0000000
--- a/src/pages/FriendPage.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import { useEffect, useState } from "react";
-import data from "../mock/user-data.json";
-
-interface Chat {
- id: number;
- name: string;
- username: string;
- profilePicture: string;
- createdAt: string;
-}
-
-export default function FriendPage() {
- const [friends, setFriends] = useState([]);
-
- useEffect(() => {
- console.log(data);
- setFriends(data);
-
- }, []);
-
-
- return (
-
-
Friend Page
-
-
-
-
-
Friends Requests
-
- {friends.map((friend) => (
-
-
-
-
-

-
-
-
{friend.name}
-
{friend.username}
-
-
-
-
-
-
-
- ))}
-
-
{friends.length} Friends
-
-
- {friends.map((friend) => (
-
-

-
- ))}
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
deleted file mode 100644
index 3081f29..0000000
--- a/src/pages/HomePage.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export default function HomePage() {
- return (
-
-
Home Page
-
- );
-}
\ No newline at end of file
diff --git a/src/pages/ChatPage.tsx b/src/pages/chat-list.page.tsx
similarity index 85%
rename from src/pages/ChatPage.tsx
rename to src/pages/chat-list.page.tsx
index 8302423..7156492 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/chat-list.page.tsx
@@ -1,19 +1,12 @@
import { useEffect, useState } from "react";
import data from "../mock/user-data.json";
+import Chat from "../types/chat";
-interface Chat {
- id: number;
- name: string;
- username: string;
- profilePicture: string;
- createdAt: string;
-}
-export default function ChatPage() {
+export default function ChatListPage() {
const [chats, setChats] = useState([]);
useEffect(() => {
- console.log(data);
setChats(data);
}, []);
diff --git a/src/pages/chat.page.tsx b/src/pages/chat.page.tsx
new file mode 100644
index 0000000..4ef6a65
--- /dev/null
+++ b/src/pages/chat.page.tsx
@@ -0,0 +1,174 @@
+import { useLoaderData, useNavigate, useParams } from "react-router-dom";
+import { SubmitHandler, useForm } from "react-hook-form";
+import { useEffect, useRef, useState } from "react";
+
+import Message from "../types/message";
+import Button from "../components/buttons/button";
+import MessageCard from "../components/cards/message.card";
+import { useFriendStore } from "../stores/friend.store";
+import { Friend } from "../types/friend";
+
+import { useMessageStore } from "../stores/message.store";
+import { useUserStore } from "../stores/user.store";
+
+import {
+ FalseMessageService,
+ MessageService,
+} from "../services/message.service";
+import { MessageAdapter } from "../adapters/message.adapter";
+import { BadRequestError } from "../errors/bad-request.error";
+import { useNotificationStore } from "../stores/notification.store";
+import { EventName } from "../services/notification.service";
+
+const MAX_MESSAGE_LENGTH = 255;
+
+export default function ChatPage() {
+ type FormInputs = {
+ content: string;
+ };
+
+ const initialMessages = useLoaderData() as Message[];
+
+ const navigate = useNavigate();
+
+ const { receiverId } = useParams();
+
+ const { messages, setMessages, addMessage, updateErrorLastMessage } =
+ useMessageStore();
+ const { id } = useUserStore();
+
+ const { service } = useNotificationStore();
+
+ const messageService: MessageAdapter = new FalseMessageService();
+ // const messageService: MessageAdapter = new MessageService();
+
+ const { getFriendById } = useFriendStore();
+
+ const [currentFriend, setCurrentFriend] = useState();
+
+ const [charactersLeft, setCharactersLeft] = useState(MAX_MESSAGE_LENGTH);
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors },
+ } = useForm({
+ defaultValues: {
+ content: "",
+ },
+ });
+
+ const sendMessage = async (message: Message) => {
+ try {
+ await messageService.sendMessage(message);
+ } catch (error) {
+ if (error instanceof BadRequestError) {
+ navigate("/");
+ } else {
+ updateErrorLastMessage(true);
+ }
+ }
+ };
+
+ const onSubmit: SubmitHandler = async (input) => {
+ const messageId = crypto.randomUUID();
+ if (!receiverId) return;
+ const message: Message = {
+ id: messageId,
+ content: input.content,
+ receiverId: receiverId,
+ emitterId: id,
+ sendAt: new Date().toISOString(),
+ };
+ addMessage(message);
+ await sendMessage(message);
+ setCharactersLeft(MAX_MESSAGE_LENGTH);
+ reset();
+ };
+
+ const retryMessage = async (message: Message) => {
+ updateErrorLastMessage(false);
+ await sendMessage(message);
+ };
+
+ useEffect(() => {
+ if (!receiverId) return;
+
+ setCurrentFriend(getFriendById(receiverId));
+ setMessages(initialMessages);
+
+ const handleNewMessage = (message: Message) => {
+ addMessage(message);
+ };
+
+ const eventSource = service.eventListener(
+ handleNewMessage,
+ EventName.MESSAGE_RECEIVED
+ );
+
+ return () => {
+ eventSource.close();
+ };
+ }, [receiverId]);
+
+ const messagesBox = useRef(null);
+ function scrollToBottom() {
+ if (messagesBox.current) {
+ messagesBox.current.scrollTop = messagesBox.current.scrollHeight;
+ }
+ }
+ useEffect(() => {
+ scrollToBottom();
+ console.log(messages);
+ }, [messages]);
+
+ return (
+
+ {currentFriend && (
+
{currentFriend.username} Chat
+ )}
+
+ {messages.map((message, index) => (
+ retryMessage(message)}
+ />
+ ))}
+
+
+
+ {charactersLeft !== MAX_MESSAGE_LENGTH && (
+
+ {charactersLeft} characters left
+
+ )}
+
+ );
+}
diff --git a/src/pages/friend.page.tsx b/src/pages/friend.page.tsx
new file mode 100644
index 0000000..befbdb2
--- /dev/null
+++ b/src/pages/friend.page.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useState } from "react";
+import FriendRequestCard from "../components/friend-request-card.component";
+import { fetchFriendRequests } from "../services/friend-request.service";
+import Loader from "../components/loaders/spinner/loader.component";
+import { FriendRequest } from "../types/friend-request";
+import AddFriend from "../components/add-friend.component";
+import { useNotificationStore } from "../stores/notification.store";
+import { EventName } from "../services/notification.service";
+
+export default function FriendPage() {
+ const [friendRequests, setFriendRequests] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const { service } = useNotificationStore();
+
+ const removeFriendRequest = (friendRequest: FriendRequest) => {
+ setFriendRequests((prevRequests) =>
+ prevRequests.filter((request) => request.id !== friendRequest.id)
+ );
+ };
+
+
+ useEffect(() => {
+ loadInitialRequests();
+
+ const handleNewFriendRequest = (data: any) => {
+ setFriendRequests(prevRequests => { return [data, ...prevRequests]; });
+ };
+ const eventSource = service.eventListener(handleNewFriendRequest, EventName.FRIEND_REQUEST_RECEIVED);
+
+ return () => {
+ eventSource.close();
+ };
+
+ }, []);
+
+
+
+
+
+ async function loadInitialRequests() {
+ try {
+ const requests = await fetchFriendRequests();
+ setFriendRequests(requests);
+ } catch (error) {
+ console.error("Error loading friend requests:", error);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ {loading ?
: null}
+
+
+
+
+
+ {/*
Friends Requests */}
+
+ {friendRequests.map((request) => (
+
+
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/home.page.tsx b/src/pages/home.page.tsx
new file mode 100644
index 0000000..8707196
--- /dev/null
+++ b/src/pages/home.page.tsx
@@ -0,0 +1,54 @@
+import { useEffect } from "react";
+import { getUserFriends } from "../services/friend.service";
+import { useNavigate } from "react-router-dom";
+import { useFriendStore } from "../stores/friend.store";
+import FriendCard from "../components/cards/friend.card";
+import FriendCardPlaceholder from "../components/cards/friend-placeholder.card";
+
+export default function HomePage() {
+ const { friends, setFriends } = useFriendStore();
+
+ const loadFriends = async () => {
+ if (friends.length === 0) {
+ const friends = await getUserFriends();
+ setFriends(friends);
+ }
+ };
+
+ const sortedByDateFriendsList = friends.sort((a, b) => {
+ return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
+ });
+
+ useEffect(() => {
+ loadFriends();
+ }, []);
+
+ const navigate = useNavigate();
+
+ const redirectUserToChatPage = (
+ event: React.MouseEvent,
+ userId: string
+ ) => {
+ event.preventDefault();
+ navigate(`/chats/${userId}`);
+ };
+
+ return (
+
+
+ {sortedByDateFriendsList.map((friend) => (
+ - redirectUserToChatPage(e, friend.userId)}
+ key={friend.userId}
+ >
+
+
+ ))}
+ {friends.length < 20 &&
+ Array.from({ length: 20 - friends.length }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/invite-link.page.tsx b/src/pages/invite-link.page.tsx
new file mode 100644
index 0000000..4e94040
--- /dev/null
+++ b/src/pages/invite-link.page.tsx
@@ -0,0 +1,23 @@
+import { useNavigate, useParams } from "react-router-dom";
+import Loader from "../components/loaders/spinner/loader.component";
+import { useEffect, useState } from "react";
+import { sendFriendRequest } from "../services/friend-request.service";
+
+export default function InviteLinkPage() {
+ let linkData = useParams();
+ const navigate = useNavigate();
+ let inviteSend = false
+
+ useEffect(() => {
+ if (linkData.senderId && !inviteSend) {
+ sendFriendRequest(linkData.senderId)
+ inviteSend = true
+ }
+ navigate("/")
+ }, []);
+
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/login.page.tsx b/src/pages/login.page.tsx
new file mode 100644
index 0000000..f9f2ab2
--- /dev/null
+++ b/src/pages/login.page.tsx
@@ -0,0 +1,35 @@
+import { useNavigate } from "react-router-dom";
+import { getCurrentUser, loginUser } from "../services/auth.service";
+import AuthForm from "../components/forms/auth.form";
+import { UserDTO } from "../dtos/user.dto";
+import { useUserStore } from "../stores/user.store";
+import Button from "../components/buttons/button";
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const { updateUser } = useUserStore();
+
+ const onSubmit = async (data: UserDTO) => {
+ await loginUser(data);
+
+ const currentUser = await getCurrentUser();
+ updateUser(currentUser);
+
+ navigate("/");
+ };
+ return (
+
+
+ Wii Login
+
+
+
+
+ );
+}
diff --git a/src/pages/notifications.page.tsx b/src/pages/notifications.page.tsx
new file mode 100644
index 0000000..f706865
--- /dev/null
+++ b/src/pages/notifications.page.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useState } from "react";
+import { Friend } from "../types/friend";
+import { getUserFriends } from "../services/friend.service";
+import Notification from "../types/notification";
+import { countUnseenNotifications } from "../utils/count-unseen-notifications";
+import { useNotificationStore } from "../stores/notification.store";
+
+export default function NotificationListPage() {
+ const { notifications, setNotifications } = useNotificationStore();
+
+ const [friends, setFriends] = useState([]);
+
+ useEffect(() => {
+ getUserFriends().then((fetchedFriends) => {
+ setFriends(fetchedFriends);
+
+ const updatedNotifications = notifications.map(
+ (notification: Notification) => {
+ if (notification.isSeen === false) {
+ return {
+ ...notification,
+ isSeen: true,
+ };
+ }
+ if (notification.status === "my-friend-request-accepted") {
+ const selectedFriend = fetchedFriends.find(
+ (friend: Friend) => friend.userId === notification.emitterId
+ );
+
+ if (selectedFriend) {
+ return {
+ ...notification,
+ status: "friend-accepted",
+ emitterUsername: selectedFriend.username,
+ };
+ }
+ }
+ if (notification.didIAccept) {
+ const selectedFriend = fetchedFriends.find(
+ (friend: Friend) => friend.userId === notification.emitterId
+ );
+
+ if (selectedFriend) {
+ return {
+ ...notification,
+ status: "friend-accepted",
+ emitterUsername: selectedFriend.username,
+ };
+ }
+ }
+ return notification;
+ }
+ );
+
+ setNotifications(updatedNotifications);
+ localStorage.setItem(
+ "notifications",
+ JSON.stringify(updatedNotifications)
+ );
+ countUnseenNotifications(notifications);
+ });
+ }, []);
+
+ if (!notifications || notifications.length === 0) {
+ return (
+
+
Notifications
+
No notifications to display
+
+ );
+ }
+
+ return (
+
+
Notifications
+
+ {notifications.map((notification: Notification) => (
+
+
+ {notification.status === "friend-accepted" && (
+
+ {notification.emitterUsername}'s request approved by you
+
+ )}
+ {notification.status === "my-friend-request-accepted" && (
+
+ {notification.emitterId} accepted your friend request
+
+ )}
+ {notification.status === "pending-request" && (
+ {notification.emitterId} sent you a friend request
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/register.page.tsx b/src/pages/register.page.tsx
new file mode 100644
index 0000000..5158a04
--- /dev/null
+++ b/src/pages/register.page.tsx
@@ -0,0 +1,29 @@
+import { useNavigate } from "react-router-dom";
+import { registerUser } from "../services/auth.service";
+import AuthForm from "../components/forms/auth.form";
+import { UserDTO } from "../dtos/user.dto";
+import Button from "../components/buttons/button";
+
+export default function RegisterPage() {
+ const navigate = useNavigate();
+
+ const onSubmit = async (data: UserDTO) => {
+ await registerUser(data);
+ navigate("/");
+ };
+ return (
+
+
+ Wii Register
+
+
+
+
+ );
+}
diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts
new file mode 100644
index 0000000..522fad6
--- /dev/null
+++ b/src/services/auth.service.ts
@@ -0,0 +1,79 @@
+import { UserDTO } from "../dtos/user.dto";
+import { User } from "../types/user";
+
+export async function loginUser(data: UserDTO) {
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/auth/login`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!response.ok) {
+ const { message } = await response.json();
+ throw new Error(message);
+ }
+}
+
+export async function registerUser(data: UserDTO) {
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/auth/register`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: JSON.stringify(data),
+ }
+ );
+ if (!response.ok) {
+ const { message } = await response.json();
+ throw new Error(message);
+ }
+}
+
+export async function logoutUser() {
+
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/auth/logout`,
+ {
+ method: "POST",
+ credentials: "include",
+ }
+ );
+ if (!response.ok) {
+ const { message } = await response.json();
+ throw new Error(message);
+ }
+ localStorage.clear();
+}
+
+export async function getCurrentUser(): Promise {
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/auth/me`,
+ {
+ credentials: "include",
+ }
+ );
+ if (!response.ok) {
+ const { message } = await response.json();
+ throw new Error(message);
+ }
+ return response.json();
+}
+
+export async function checkUserAuth() {
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/auth/me`,
+ {
+ credentials: "include",
+ }
+ );
+
+ return response.ok ? true : false;
+}
diff --git a/src/services/friend-request.service.ts b/src/services/friend-request.service.ts
new file mode 100644
index 0000000..0e86c0b
--- /dev/null
+++ b/src/services/friend-request.service.ts
@@ -0,0 +1,48 @@
+import { FriendRequest } from "../types/friend-request";
+
+export async function fetchFriendRequests() {
+ try {
+ const response = await fetch(`${process.env.REACT_APP_API_BASE_URL}/social/friend-requests`, {
+ credentials: 'include'
+ });
+ const friendRequests = await response.json();
+ return friendRequests.map((request: FriendRequest) => ({
+ ...request,
+ }));
+ } catch (error) {
+ console.error('Error fetching friend requests:', error);
+ throw error;
+ }
+}
+
+export const sendFriendRequest = async (receiverId: string) => {
+ try {
+ const randomuuid = crypto.randomUUID();
+ await fetch(`${process.env.REACT_APP_API_BASE_URL}/social/friend-request/${randomuuid}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({ receiverId: receiverId }),
+ });
+ } catch (error) {
+ console.error('Error sending friend request:', error);
+ throw error;
+ }
+}
+
+export const acceptRequest = async (requestId: string) => {
+ try {
+ await fetch(`${process.env.REACT_APP_API_BASE_URL}/social/friend-request/${requestId}/accept`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ });
+ } catch (error) {
+ console.error('Error accepting friend request:', error);
+ throw error;
+ }
+}
\ No newline at end of file
diff --git a/src/services/friend.service.ts b/src/services/friend.service.ts
new file mode 100644
index 0000000..cee7e9c
--- /dev/null
+++ b/src/services/friend.service.ts
@@ -0,0 +1,18 @@
+import { Friend } from "../types/friend";
+
+export const getUserFriends = async (): Promise => {
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/social/friends`,
+ {
+ method: "GET",
+ credentials: "include",
+ }
+ );
+ if (!response.ok) {
+ const { message } = await response.json();
+ console.error(message);
+ return [];
+ }
+
+ return (await response.json()) as Friend[];
+};
diff --git a/src/services/message.service.ts b/src/services/message.service.ts
new file mode 100644
index 0000000..14d878f
--- /dev/null
+++ b/src/services/message.service.ts
@@ -0,0 +1,102 @@
+import Message from "../types/message";
+import { MessageAdapter } from "../adapters/message.adapter";
+import { BadRequestError } from "../errors/bad-request.error";
+
+export class MessageService implements MessageAdapter {
+ sendMessage = async (message: Message): Promise => {
+ console.log(
+ `${process.env.REACT_APP_API_BASE_URL}/chat/${message.id}/send`
+ );
+ console.log({ receiverId: message.receiverId, content: message.content });
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/chat/${message.id}/send`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: JSON.stringify({
+ receiverId: message.receiverId,
+ content: message.content,
+ }),
+ }
+ );
+ console.log(response);
+
+ if (response.status === 400) {
+ throw new BadRequestError("You are not friend with this user");
+ } else {
+ if (!response.ok) {
+ throw new Error(`Error ${response.status}: ${response.statusText}`);
+ }
+ }
+ };
+
+ fetchMessages = async (receiverId: string): Promise => {
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/messages/${receiverId}/`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ }
+ );
+ if (!response.ok) {
+ throw new Error(`Error ${response.status}: ${response.statusText}`);
+ }
+ return await response.json();
+ };
+}
+
+export class FalseMessageService implements MessageAdapter {
+ async sendMessage(message: Message): Promise {
+ const random = Math.random() * 10;
+
+ if (random < 5) {
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/chat/${message.id}/send`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: JSON.stringify({
+ receiverId: message.receiverId,
+ content: message.content,
+ }),
+ }
+ );
+ //TODO: Gérer plusieurs erreurs dont erreur connexion
+ if (response.status === 400) {
+ throw new BadRequestError("You are not friend with this user");
+ } else {
+ if (!response.ok) {
+ throw new Error(`Error ${response.status}: ${response.statusText}`);
+ }
+ }
+ } else {
+ throw new Error("Method not implemented.");
+ }
+ }
+
+ fetchMessages = async (receiverId: string): Promise => {
+ const response = await fetch(
+ `${process.env.REACT_APP_API_BASE_URL}/messages/${receiverId}/`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ }
+ );
+ if (!response.ok) {
+ throw new Error(`Error ${response.status}: ${response.statusText}`);
+ }
+ return await response.json();
+ };
+}
diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts
new file mode 100644
index 0000000..e0e1ec4
--- /dev/null
+++ b/src/services/notification.service.ts
@@ -0,0 +1,22 @@
+import Message from "../types/message";
+
+export enum EventName {
+ FRIEND_REQUEST_RECEIVED = 'friend-request-received',
+ FRIEND_REQUEST_ACCEPTED = 'friend-request-accepted',
+ MESSAGE_RECEIVED = 'message-received',
+}
+
+export class NotificationService {
+
+ eventListener = (onElementReceived: (data: any) => void, eventName: EventName) => {
+ const eventSource = new EventSource(`${process.env.REACT_APP_API_BASE_URL}/notifications`, { withCredentials: true })
+ eventSource.addEventListener(eventName, (event) => {
+ const data = JSON.parse(event.data);
+ onElementReceived(data);
+ })
+ eventSource.onerror = (error) => {
+ eventSource.close();
+ };
+ return eventSource;
+ }
+}
\ No newline at end of file
diff --git a/src/stores/friend.store.ts b/src/stores/friend.store.ts
new file mode 100644
index 0000000..8bb19fc
--- /dev/null
+++ b/src/stores/friend.store.ts
@@ -0,0 +1,25 @@
+import { create } from "zustand/react";
+import { Friend } from "../types/friend";
+
+type States = {
+ friends: Friend[];
+};
+
+type Actions = {
+ setFriends: (friends: Friend[]) => void;
+ addFriend: (friend: Friend) => void;
+ clearFriends: () => void;
+ getFriendById: (userId: string) => Friend | undefined;
+};
+
+export const useFriendStore = create((set, get) => ({
+ friends: [],
+ setFriends: (friends: Friend[]) => set({ friends }),
+ addFriend: (friend: Friend) =>
+ set((state) => ({ friends: [...state.friends, friend] })),
+ clearFriends: () => set({ friends: [] }),
+ getFriendById: (userId: string): Friend | undefined =>
+ useFriendStore
+ .getState()
+ .friends.find((friend: Friend) => friend.userId === userId),
+}));
diff --git a/src/stores/message.store.ts b/src/stores/message.store.ts
new file mode 100644
index 0000000..58bca4e
--- /dev/null
+++ b/src/stores/message.store.ts
@@ -0,0 +1,26 @@
+import { create } from "zustand/react";
+
+import Message from "../types/message";
+
+type MessageStore = {
+ messages: Message[];
+ setMessages: (messages: Message[]) => void;
+ addMessage: (message: Message) => void;
+ updateErrorLastMessage: (error: boolean) => void;
+};
+
+export const useMessageStore = create((set) => ({
+ messages: [],
+ setMessages: (messages: Message[]) => set({ messages }),
+ addMessage: (message: Message) =>
+ set((state) => ({ messages: [message, ...state.messages] })),
+ updateErrorLastMessage: (error: boolean) =>
+ set((state) => {
+ const messages = [...state.messages];
+ messages[0] = {
+ ...messages[0],
+ error,
+ };
+ return { messages };
+ }),
+}));
diff --git a/src/stores/notification.store.ts b/src/stores/notification.store.ts
new file mode 100644
index 0000000..4431037
--- /dev/null
+++ b/src/stores/notification.store.ts
@@ -0,0 +1,30 @@
+import { create } from "zustand/react";
+
+import Notification from "../types/notification";
+import { EventName, NotificationService } from "../services/notification.service";
+
+const notificationService = new NotificationService();
+
+type NotificationsStore = {
+ notifications: Notification[];
+ service: NotificationService;
+ setNotifications: (notifications: Notification[]) => void;
+ addNotification: (notification: Notification) => void;
+ getMessagesNotifications: () => Notification[];
+ getFriendRequestReceivedNotifications: () => Notification[];
+ getFriendRequestAcceptedNotifications: () => Notification[];
+};
+
+export const useNotificationStore = create((set, get) => ({
+ notifications: [],
+ service: notificationService,
+ setNotifications: (notifications: Notification[]) => set({ notifications }),
+ addNotification: (notification: Notification) =>
+ set((state) => ({ notifications: [notification, ...state.notifications] })),
+ getMessagesNotifications: () =>
+ get().notifications.filter((notification) => notification.type === EventName.MESSAGE_RECEIVED),
+ getFriendRequestReceivedNotifications: () =>
+ get().notifications.filter((notification) => notification.type === EventName.FRIEND_REQUEST_RECEIVED),
+ getFriendRequestAcceptedNotifications: () =>
+ get().notifications.filter((notification) => notification.type === EventName.FRIEND_REQUEST_ACCEPTED),
+}));
diff --git a/src/stores/user.store.ts b/src/stores/user.store.ts
new file mode 100644
index 0000000..fd2bef4
--- /dev/null
+++ b/src/stores/user.store.ts
@@ -0,0 +1,18 @@
+import { create } from "zustand";
+import { User } from "../types/user";
+
+type State = {
+ username: string;
+ id: string;
+};
+
+type Action = {
+ clearUser: () => void;
+ updateUser: (newUser: User) => void;
+};
+export const useUserStore = create((set) => ({
+ username: "",
+ id: "",
+ clearUser: () => set({ username: "", id: "" }),
+ updateUser: (newUser) => set({ username: newUser.username, id: newUser.id }),
+}));
diff --git a/src/types/chat.ts b/src/types/chat.ts
new file mode 100644
index 0000000..928cf71
--- /dev/null
+++ b/src/types/chat.ts
@@ -0,0 +1,7 @@
+export default interface Chat {
+ id: number;
+ name: string;
+ username: string;
+ profilePicture: string;
+ createdAt: string;
+}
\ No newline at end of file
diff --git a/src/types/friend-request.ts b/src/types/friend-request.ts
new file mode 100644
index 0000000..fd47801
--- /dev/null
+++ b/src/types/friend-request.ts
@@ -0,0 +1,5 @@
+export interface FriendRequest {
+ id: string;
+ senderId: string;
+ requestedAt: string;
+}
\ No newline at end of file
diff --git a/src/types/friend.ts b/src/types/friend.ts
new file mode 100644
index 0000000..4d5256c
--- /dev/null
+++ b/src/types/friend.ts
@@ -0,0 +1,5 @@
+export interface Friend {
+ userId: string;
+ username: string;
+ startedAt: string;
+}
diff --git a/src/types/message.ts b/src/types/message.ts
new file mode 100644
index 0000000..df043e8
--- /dev/null
+++ b/src/types/message.ts
@@ -0,0 +1,8 @@
+export default interface Message {
+ id: string;
+ emitterId: string;
+ receiverId: string;
+ content: string;
+ sendAt: string;
+ error?: boolean;
+}
\ No newline at end of file
diff --git a/src/types/notification.ts b/src/types/notification.ts
new file mode 100644
index 0000000..502d6a6
--- /dev/null
+++ b/src/types/notification.ts
@@ -0,0 +1,10 @@
+export default interface Notification {
+ id: string;
+ type: string;
+ emitterId?: string;
+ emitterUsername?: string;
+ receivedAt: string;
+ didIAccept?: boolean;
+ status?: string;
+ isSeen: boolean;
+}
\ No newline at end of file
diff --git a/src/types/user.ts b/src/types/user.ts
new file mode 100644
index 0000000..769bd33
--- /dev/null
+++ b/src/types/user.ts
@@ -0,0 +1,4 @@
+export interface User {
+ username: string;
+ id: string;
+}
diff --git a/src/utils/count-unseen-notifications.tsx b/src/utils/count-unseen-notifications.tsx
new file mode 100644
index 0000000..6a27df0
--- /dev/null
+++ b/src/utils/count-unseen-notifications.tsx
@@ -0,0 +1,15 @@
+import Notification from "../types/notification";
+
+export function countUnseenNotifications(notifications: Notification[]) {
+ // console.log("count");
+
+ let count = 0;
+ notifications.map((notification: Notification) => {
+ if (notification.isSeen == false || notification.didIAccept == false) {
+ count = count + 1;
+ }
+ });
+ // console.log(count);
+
+ return count.toString();
+}
\ No newline at end of file
diff --git a/src/utils/dateFormater.tsx b/src/utils/dateFormater.tsx
new file mode 100644
index 0000000..c191245
--- /dev/null
+++ b/src/utils/dateFormater.tsx
@@ -0,0 +1,27 @@
+/**
+ * A many time ago the request was sended
+ * @param dateString
+ * @returns
+*/
+
+export function dateFormater(dateString: string) {
+
+ const date = new Date(dateString);
+ const now = new Date();
+
+ const diffMs = now.getTime() - date.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHeure = Math.floor(diffMin / 60);
+ const diffJour = Math.floor(diffHeure / 24);
+
+ if (diffJour > 0) {
+ return `${diffJour} day${diffJour > 1 ? 's' : ''} ago`;
+ } else if (diffHeure > 0) {
+ return ` ${diffHeure} hour${diffHeure > 1 ? 's' : ''} ago`;
+ } else if (diffMin > 0) {
+ return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`;
+ } else {
+ return `${diffSec} second${diffSec > 1 ? 's' : ''} ago`;
+ }
+}
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
index c0958ec..9e12a2f 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,11 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
- content: [
- "./src/**/*.{js,jsx,ts,tsx}",
- ],
+ content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
+ safelist: ["btn", "primary-btn", "secondary-btn", "tertiary-btn"],
plugins: [],
-}
-
+};