+
@@ -15,82 +62,20 @@ export const Login = () => {
-
-
-
-
- Not a member?{" "}
-
- Start a 14 day free trial
-
-
-
+
handleLogin(resp.credential ?? "")}
+ onError={() => {
+ void pushToast({
+ type: "error",
+ title: "Login Error",
+ message:
+ "An error occurred while trying to log in with a Google account",
+ });
+ }}
+ />
);
};
diff --git a/resources/js/screens/Settings.tsx b/resources/js/screens/Settings.tsx
deleted file mode 100644
index b1093751882..00000000000
--- a/resources/js/screens/Settings.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-export const Settings = () => {
- return (
-
-
-
-
- Personal Information
-
-
- Use a permanent address where you can receive mail.
-
-
-
-
-
-
-
-
-
- Change password
-
-
- Update your password associated with your account.
-
-
-
-
-
-
-
-
-
- Log out other sessions
-
-
- Please enter your password to confirm you would like to log out of
- your other sessions across all of your devices.
-
-
-
-
-
-
-
-
-
- Delete account
-
-
- No longer want to use our service? You can delete your account here.
- This action is not reversible. All information related to this
- account will be deleted permanently.
-
-
-
-
-
-
- );
-};
diff --git a/resources/js/screens/Users.tsx b/resources/js/screens/Users.tsx
new file mode 100644
index 00000000000..31c604d24ad
--- /dev/null
+++ b/resources/js/screens/Users.tsx
@@ -0,0 +1,220 @@
+import { tw } from "@/utils";
+
+const statuses = {
+ Completed: "text-green-400 bg-green-400/10",
+ Error: "text-rose-400 bg-rose-400/10",
+};
+const activityItems = [
+ {
+ user: {
+ name: "Michael Foster",
+ imageUrl:
+ "https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
+ },
+ commit: "2d89f0c8",
+ branch: "main",
+ status: "Completed",
+ duration: "25s",
+ date: "45 minutes ago",
+ dateTime: "2023-01-23T11:00",
+ },
+ {
+ user: {
+ name: "Lindsay Walton",
+ imageUrl:
+ "https://images.unsplash.com/photo-1517841905240-472988babdf9?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
+ },
+ commit: "249df660",
+ branch: "main",
+ status: "Completed",
+ duration: "1m 32s",
+ date: "3 hours ago",
+ dateTime: "2023-01-23T09:00",
+ },
+ {
+ user: {
+ name: "Courtney Henry",
+ imageUrl:
+ "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
+ },
+ commit: "11464223",
+ branch: "main",
+ status: "Error",
+ duration: "1m 4s",
+ date: "12 hours ago",
+ dateTime: "2023-01-23T00:00",
+ },
+ {
+ user: {
+ name: "Courtney Henry",
+ imageUrl:
+ "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
+ },
+ commit: "dad28e95",
+ branch: "main",
+ status: "Completed",
+ duration: "2m 15s",
+ date: "2 days ago",
+ dateTime: "2023-01-21T13:00",
+ },
+ {
+ user: {
+ name: "Michael Foster",
+ imageUrl:
+ "https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
+ },
+ commit: "624bc94c",
+ branch: "main",
+ status: "Completed",
+ duration: "1m 12s",
+ date: "5 days ago",
+ dateTime: "2023-01-18T12:34",
+ },
+ {
+ user: {
+ name: "Courtney Henry",
+ imageUrl:
+ "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
+ },
+ commit: "e111f80e",
+ branch: "main",
+ status: "Completed",
+ duration: "1m 56s",
+ date: "1 week ago",
+ dateTime: "2023-01-16T15:54",
+ },
+ {
+ user: {
+ name: "Michael Foster",
+ imageUrl:
+ "https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
+ },
+ commit: "5e136005",
+ branch: "main",
+ status: "Completed",
+ duration: "3m 45s",
+ date: "1 week ago",
+ dateTime: "2023-01-16T11:31",
+ },
+ {
+ user: {
+ name: "Whitney Francis",
+ imageUrl:
+ "https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80",
+ },
+ commit: "5c1fd07f",
+ branch: "main",
+ status: "Completed",
+ duration: "37s",
+ date: "2 weeks ago",
+ dateTime: "2023-01-09T08:45",
+ },
+];
+
+export const Users = () => {
+ return (
+
+
+ Latest activity
+
+
+
+
+
+
+
+
+
+
+
+
+ User
+
+
+ Commit
+
+
+ Status
+
+
+ Duration
+
+
+ Deployed at
+
+
+
+
+ {activityItems.map((item) => (
+
+
+
+
+
+ {item.user.name}
+
+
+
+
+
+
+ {item.commit}
+
+
+ {item.branch}
+
+
+
+
+
+
+ {item.date}
+
+
+
+ {item.status}
+
+
+
+
+ {item.duration}
+
+
+ {item.date}
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/resources/js/screens/index.ts b/resources/js/screens/index.ts
index 36a3532414c..11b42c9afd0 100644
--- a/resources/js/screens/index.ts
+++ b/resources/js/screens/index.ts
@@ -1,2 +1,4 @@
-export * from "./Settings";
+export * from "./Home";
+export * from "./Login";
export * from "./NotFound";
+export * from "./Users";
diff --git a/resources/js/stores/useUserStore.ts b/resources/js/stores/useUserStore.ts
index 1aaca843093..de951adca31 100644
--- a/resources/js/stores/useUserStore.ts
+++ b/resources/js/stores/useUserStore.ts
@@ -1,22 +1,38 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-interface UserStore {
- token: string | null;
+export interface User {
+ email: string;
+ name: string;
+ picture: string;
+ role: "standard" | "admin";
+}
+export interface UserStoreState {
+ user: User | null;
+ token: string | null;
+ setUser: (user: User | null) => void;
setToken: (token: string | null) => void;
+ clearUser: () => void;
}
-export const useUserStore = create
()(
+export const useUserStore = create()(
persist(
(set) => ({
+ user: null,
token: null,
+ setUser: (user: User | null) => {
+ set(() => ({ user }));
+ },
setToken: (token: string | null) => {
set(() => ({ token }));
},
+ clearUser: () => {
+ set({ user: null, token: null });
+ },
}),
{
- name: "useUserStore",
+ name: "feedbackUserStore",
},
),
);
diff --git a/resources/js/ui/Button.tsx b/resources/js/ui/Button.tsx
new file mode 100644
index 00000000000..ce15761ffa7
--- /dev/null
+++ b/resources/js/ui/Button.tsx
@@ -0,0 +1,75 @@
+import type { ComponentPropsWithoutRef, ForwardedRef } from "react";
+
+import { forwardRef, tw } from "@/utils";
+
+const BUTTON_VARIANT = {
+ PRIMARY: "primary",
+ SECONDARY: "secondary",
+ OUTLINE: "outline",
+ TERTIARY: "tertiary",
+} as const;
+type ButtonVariant = (typeof BUTTON_VARIANT)[keyof typeof BUTTON_VARIANT];
+
+const SIZE = {
+ SMALL: "sm",
+ MEDIUM: "md",
+ LARGE: "lg",
+} as const;
+type Size = (typeof SIZE)[keyof typeof SIZE];
+
+export interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
+ variant?: ButtonVariant;
+ size?: Size;
+}
+
+export const Button = forwardRef(
+ (
+ {
+ type = "button",
+ className,
+ variant = "primary",
+ size = "md",
+ disabled = false,
+ children,
+ ...props
+ }: ButtonProps,
+ ref: ForwardedRef,
+ ) => (
+
+ {children}
+
+ ),
+);
diff --git a/resources/js/components/Icons.tsx b/resources/js/ui/Icons.tsx
similarity index 100%
rename from resources/js/components/Icons.tsx
rename to resources/js/ui/Icons.tsx
diff --git a/resources/js/ui/Toast/ToastMessage.tsx b/resources/js/ui/Toast/ToastMessage.tsx
new file mode 100644
index 00000000000..0bdef11cadd
--- /dev/null
+++ b/resources/js/ui/Toast/ToastMessage.tsx
@@ -0,0 +1,68 @@
+import { Fragment } from "react";
+import { Transition } from "@headlessui/react";
+import { XMarkIcon } from "@heroicons/react/20/solid";
+
+import toastIcons from "./toastIcons";
+import type { Toast } from "./toastStore";
+
+export interface ToastMessageProps {
+ toast: Toast;
+ onClose: () => void;
+}
+
+export const ToastMessage = ({ toast, onClose }: ToastMessageProps) => {
+ return (
+
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ onClose();
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ className="pointer-events-auto z-50 w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5"
+ >
+
+
+
+ {toast.icon ? toast.icon : toastIcons[toast.type]}
+
+
+
+ {toast.title && (
+
+ {toast.title}
+
+ )}
+ {toast.message && (
+
{toast.message}
+ )}
+
+
+
+ Close
+
+
+
+
+
+
+
+ );
+};
diff --git a/resources/js/ui/Toast/Toasts.tsx b/resources/js/ui/Toast/Toasts.tsx
new file mode 100644
index 00000000000..197134885bb
--- /dev/null
+++ b/resources/js/ui/Toast/Toasts.tsx
@@ -0,0 +1,23 @@
+import { ToastMessage } from "./ToastMessage";
+import { useToastStore } from "./toastStore";
+
+export const Toasts = () => {
+ const { toasts, deleteToast } = useToastStore();
+
+ return !toasts.length ? null : (
+
+
+ {toasts.map((toast) => (
+ void deleteToast(toast.id)}
+ />
+ ))}
+
+
+ );
+};
diff --git a/resources/js/ui/Toast/errorToast.ts b/resources/js/ui/Toast/errorToast.ts
new file mode 100644
index 00000000000..2cf8bd581d4
--- /dev/null
+++ b/resources/js/ui/Toast/errorToast.ts
@@ -0,0 +1,45 @@
+import { z } from "zod";
+
+import { useToastStore } from "./toastStore";
+
+const axiosErrorSchema = z.object({
+ response: z.object({
+ data: z.object({
+ status: z.number(),
+ success: z.boolean(),
+ error: z.object({
+ code: z.string(),
+ message: z.string(),
+ }),
+ }),
+ }),
+});
+
+export const validateError = (data: unknown) => {
+ const parsedError = axiosErrorSchema.safeParse(data);
+
+ if (parsedError.success) return parsedError.data;
+
+ return undefined;
+};
+
+export const errorToast = (error: unknown): void => {
+ const pushToast = useToastStore.getState().pushToast;
+
+ const validatedError = validateError(error);
+
+ if (validatedError) {
+ void pushToast({
+ type: "error",
+ title: "Validation Error",
+ message: validatedError.response.data.error.message,
+ });
+ } else {
+ console.error(error);
+ void pushToast({
+ type: "error",
+ title: "Error",
+ message: "Unknown error",
+ });
+ }
+};
diff --git a/resources/js/ui/Toast/index.ts b/resources/js/ui/Toast/index.ts
new file mode 100644
index 00000000000..e42bebd4e1a
--- /dev/null
+++ b/resources/js/ui/Toast/index.ts
@@ -0,0 +1,3 @@
+export * from "./Toasts";
+export * from "./errorToast";
+export * from "./toastStore";
diff --git a/resources/js/ui/Toast/toastIcons.tsx b/resources/js/ui/Toast/toastIcons.tsx
new file mode 100644
index 00000000000..5ba020842e7
--- /dev/null
+++ b/resources/js/ui/Toast/toastIcons.tsx
@@ -0,0 +1,15 @@
+import {
+ CheckCircleIcon,
+ ExclamationCircleIcon,
+ InformationCircleIcon,
+ XCircleIcon,
+} from "@heroicons/react/24/outline";
+
+const toastIcons = {
+ info: ,
+ success: ,
+ warning: ,
+ error: ,
+};
+
+export default toastIcons;
diff --git a/resources/js/ui/Toast/toastStore.ts b/resources/js/ui/Toast/toastStore.ts
new file mode 100644
index 00000000000..98814239492
--- /dev/null
+++ b/resources/js/ui/Toast/toastStore.ts
@@ -0,0 +1,72 @@
+import type { ReactNode } from "react";
+import { v4 as uuid } from "uuid";
+import { create } from "zustand";
+
+import { asyncTimeout } from "@/utils/asyncTimeout";
+
+export const toastTypes = ["info", "success", "error", "warning"] as const;
+
+export type ToastType = (typeof toastTypes)[number];
+
+export interface Toast {
+ id: string;
+ type: ToastType;
+ icon: ReactNode;
+ title: string;
+ message: string;
+ timestamp: number; // date.now()
+ duration: number; // in ms
+ state: "open" | "isClosing";
+}
+
+export interface ToastStore {
+ toasts: Toast[];
+ pushToast: (newToast: Partial) => Promise;
+ deleteToast: (id: string) => Promise;
+}
+
+export const useToastStore = create((set, get) => ({
+ toasts: [],
+ pushToast: async (toast = {}) => {
+ const newToast = {
+ id: uuid(),
+ type: "info",
+ timestamp: Date.now(),
+ icon: null,
+ duration: 5000,
+ state: "open",
+ ...toast,
+ } as Toast;
+
+ set((state) => ({
+ toasts: state.toasts.concat(newToast),
+ }));
+ // let's wait for duration and THEN delete this toast if it exists
+ await asyncTimeout(newToast.duration);
+
+ void get().deleteToast(newToast.id);
+ },
+ deleteToast: async (id) => {
+ set((state) => {
+ const toastIdx = state.toasts.findIndex((t) => t.id === id);
+ const toast = state.toasts[toastIdx];
+
+ if (toast) {
+ return {
+ toasts: state.toasts
+ .slice(0, toastIdx)
+ .concat({ ...toast, state: "isClosing" })
+ .concat(state.toasts.slice(toastIdx + 1)),
+ };
+ }
+
+ return state;
+ });
+
+ await asyncTimeout(600);
+
+ set((state) => ({
+ toasts: state.toasts.filter((toast) => toast.id !== id),
+ }));
+ },
+}));
diff --git a/resources/js/ui/index.ts b/resources/js/ui/index.ts
new file mode 100644
index 00000000000..e3354e8a416
--- /dev/null
+++ b/resources/js/ui/index.ts
@@ -0,0 +1,2 @@
+export * from "./Icons";
+export * from "./Toast";
diff --git a/resources/js/utils/asyncTimeout.ts b/resources/js/utils/asyncTimeout.ts
new file mode 100644
index 00000000000..c107249e177
--- /dev/null
+++ b/resources/js/utils/asyncTimeout.ts
@@ -0,0 +1,2 @@
+export const asyncTimeout = (ms: number) =>
+ new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/resources/js/utils/forwardRef.ts b/resources/js/utils/forwardRef.ts
new file mode 100644
index 00000000000..4e3722e92cc
--- /dev/null
+++ b/resources/js/utils/forwardRef.ts
@@ -0,0 +1,8 @@
+import React from "react";
+
+// Here we are re-declaring the forwardRef type to support generics being passed to it
+
+// eslint-disable-next-line @typescript-eslint/ban-types
+export const forwardRef = React.forwardRef as (
+ render: (props: P, ref: React.Ref) => React.ReactElement | null,
+) => (props: P & React.RefAttributes) => React.ReactElement | null;
diff --git a/resources/js/utils/handleAxiosFieldErrors.ts b/resources/js/utils/handleAxiosFieldErrors.ts
new file mode 100644
index 00000000000..80fea4a4264
--- /dev/null
+++ b/resources/js/utils/handleAxiosFieldErrors.ts
@@ -0,0 +1,55 @@
+import type { FieldValues, Path, UseFormSetError } from "react-hook-form";
+import { z } from "zod";
+
+const axiosFormErrorSchema = z.object({
+ response: z.object({
+ data: z.object({
+ status: z.number(),
+ success: z.boolean(),
+ error: z.object({
+ code: z.string(),
+ message: z.string(),
+ fields: z.record(z.array(z.string())),
+ }),
+ }),
+ }),
+});
+
+export const validateFormError = (data: unknown) => {
+ const parsedError = axiosFormErrorSchema.safeParse(data);
+
+ if (parsedError.success) return parsedError.data;
+
+ return undefined;
+};
+
+export const parseAxiosFormErrors = >(
+ apiError?: unknown,
+): Partial> => {
+ const parsedError = axiosFormErrorSchema.safeParse(apiError);
+ if (parsedError.success) {
+ // TODO: find a way to avoid this casting
+ return parsedError.data.response.data.error.fields as object;
+ }
+
+ return {};
+};
+
+export const handleAxiosFieldErrors = (
+ err: unknown,
+ setError: UseFormSetError,
+) => {
+ const formErrors = parseAxiosFormErrors(err);
+ const entries = Object.entries(formErrors) as [Path, string[]][];
+
+ entries.forEach(([fieldName, errors]) => {
+ const firstError = errors[0];
+ if (firstError) {
+ setError(
+ fieldName,
+ { type: "backend", message: firstError },
+ { shouldFocus: true },
+ );
+ }
+ });
+};
diff --git a/resources/js/utils/index.ts b/resources/js/utils/index.ts
index 7b290ff5f0d..0bb12664c6e 100644
--- a/resources/js/utils/index.ts
+++ b/resources/js/utils/index.ts
@@ -1 +1,4 @@
+export * from "./asyncTimeout";
+export * from "./forwardRef";
+export * from "./handleAxiosFieldErrors";
export * from "./tw";
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
index ef8cb3fff5c..b367e5bba29 100644
--- a/resources/views/app.blade.php
+++ b/resources/views/app.blade.php
@@ -7,328 +7,20 @@
Laravel
+
+
-
-
-
- @viteReactRefresh @vite('resources/js/app.tsx')
+ @viteReactRefresh @vite('resources/js/App.tsx')