diff --git a/.env.example b/.env.example deleted file mode 100644 index 27599447..00000000 --- a/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -NEXT_PUBLIC_APPWRITE_API_URL= -NEXT_PUBLIC_APPWRITE_PROJECT_ID= -NEXT_PUBLIC_APPWRITE_DATABASE_ID= -APPWRITE_API_KEY= \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts index 04d7722b..691fc39c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -2,6 +2,7 @@ import type { StorybookConfig } from '@storybook/nextjs'; const config: StorybookConfig = { stories: [ + '../stories/*.mdx', '../stories/**/*.mdx', '../components/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], @@ -12,6 +13,7 @@ const config: StorybookConfig = { '@storybook/addon-essentials', '@chromatic-com/storybook', '@storybook/addon-interactions', + '@storybook/manager-api', ], framework: { name: '@storybook/nextjs', diff --git a/.storybook/manager.js b/.storybook/manager.js new file mode 100644 index 00000000..e73de14d --- /dev/null +++ b/.storybook/manager.js @@ -0,0 +1,8 @@ +import { addons } from '@storybook/manager-api'; + +addons.setConfig({ + sidebar: { + showRoots: true, + collapsedRoots: ['about', 'technical-planning-documents'], + }, +}); diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 40b5e67a..9de16511 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,5 +1,6 @@ import type { Preview } from '@storybook/react'; import '../app/globals.css'; +import '../stories/styles.css'; const preview: Preview = { parameters: { @@ -9,13 +10,7 @@ const preview: Preview = { date: /Date$/i, }, }, - backgrounds: { - default: 'dark', - values: [ - { name: 'dark', value: '#09090B' }, - { name: 'light', value: '#fff' }, - ], - }, + layout: 'centered', }, }; diff --git a/api/apiFunctions.test.tsx b/api/apiFunctions.test.tsx index 30a7b3a5..7be6a988 100644 --- a/api/apiFunctions.test.tsx +++ b/api/apiFunctions.test.tsx @@ -1,12 +1,17 @@ +import { mock } from 'node:test'; import { recoverPassword, registerAccount, + resetPassword, resetRecoveredPassword, + updateUserEmail, } from './apiFunctions'; import { IUser } from './apiFunctions.interface'; -import { account, ID } from './config'; +import { account, databases, ID } from './config'; const apiFunctions = require('./apiFunctions'); import { getBaseURL } from '@/utils/getBaseUrl'; +import { Collection } from './apiFunctions.enum'; +import { Query } from 'appwrite'; jest.mock('./apiFunctions', () => { const actualModule = jest.requireActual('./apiFunctions'); @@ -30,8 +35,17 @@ jest.mock('./config', () => ({ account: { create: jest.fn(), createRecovery: jest.fn(), + updateEmail: jest.fn(), + updatePassword: jest.fn(), updateRecovery: jest.fn(), }, + appwriteConfig: { + databaseId: 'mock-database-id', + }, + databases: { + listDocuments: jest.fn(), + updateDocument: jest.fn(), + }, ID: { unique: jest.fn(), }, @@ -164,6 +178,110 @@ describe('apiFunctions', () => { ); }); }); + describe('updateUserEmail', () => { + const mockNewEmail = 'new@example.com'; + const mockPassword = 'password123'; + const mockUserId = '123'; + const mockDocumentId = '456'; + it("should successfully update the user's email", async () => { + (account.updateEmail as jest.Mock).mockResolvedValue({ + $id: mockUserId, + }); + + (databases.listDocuments as jest.Mock).mockResolvedValue({ + documents: [ + { + $id: mockDocumentId, + name: 'Test User', + email: 'old@example.com', + labels: '', + userId: mockUserId, + leagues: [], + }, + ], + }); + + (databases.updateDocument as jest.Mock).mockResolvedValue({}); + + await updateUserEmail({ + email: mockNewEmail, + password: mockPassword, + }); + + expect(account.updateEmail).toHaveBeenCalledWith( + mockNewEmail, + mockPassword, + ); + + expect(databases.listDocuments).toHaveBeenCalledWith( + 'mock-database-id', + Collection.USERS, + [Query.equal('userId', mockUserId)], + ); + + expect(databases.updateDocument).toHaveBeenCalledWith( + 'mock-database-id', + Collection.USERS, + mockDocumentId, + { + email: mockNewEmail, + name: 'Test User', + labels: '', + userId: mockUserId, + leagues: [], + }, + ); + }); + it('should throw an error if updating email fails', async () => { + (account.updateEmail as jest.Mock).mockRejectedValue(new Error()); + + await expect( + updateUserEmail({ + email: mockNewEmail, + password: mockPassword, + }), + ).rejects.toThrow(); + + expect(account.updateEmail).toHaveBeenCalledWith( + mockNewEmail, + mockPassword, + ); + + expect(databases.listDocuments).not.toHaveBeenCalled(); + expect(databases.updateDocument).not.toHaveBeenCalled(); + }); + }); + + describe('resetPassword', () => { + it('should successfully reset the password', async () => { + (account.updatePassword as jest.Mock).mockResolvedValue({}); + + await resetPassword({ + newPassword: 'newPassword123', + oldPassword: 'oldPassword123', + }); + + expect(account.updatePassword).toHaveBeenCalledWith( + 'newPassword123', + 'oldPassword123', + ); + }); + }); + it('should throw an error if resetting password fails', async () => { + (account.updatePassword as jest.Mock).mockRejectedValue(new Error()); + + await expect( + resetPassword({ + newPassword: 'newPassword123', + oldPassword: 'oldPassword123', + }), + ).rejects.toThrow(); + + expect(account.updatePassword).toHaveBeenCalledWith( + 'newPassword123', + 'oldPassword123', + ); + }); }); describe('Get Weekly Picks Mock function', () => { diff --git a/api/apiFunctions.ts b/api/apiFunctions.ts index 79b15cc3..d9b31470 100644 --- a/api/apiFunctions.ts +++ b/api/apiFunctions.ts @@ -83,6 +83,67 @@ export async function resetRecoveredPassword({ } } +/** + * Resets a user's password from the settings page + * @param params - The params for the reset password function + * @param params.newPassword - The new password + * @param params.oldPassword - The old password + * @returns {Promise} + */ +export async function resetPassword({ + newPassword, + oldPassword, +}: { + newPassword: string; + oldPassword: string; +}): Promise { + try { + await account.updatePassword(newPassword, oldPassword); + } catch (error) { + throw error; + } +} + +/** + * Update the user email + * @param props - The props for the update email function + * @param props.email - The email + * @param props.password - The user's current password + * @returns {Promise} - The updated user + */ +export async function updateUserEmail({ + email, + password, +}: { + email: string; + password: string; +}): Promise { + try { + const result = await account.updateEmail(email, password); + + const userDocument = await databases.listDocuments( + appwriteConfig.databaseId, + Collection.USERS, + [Query.equal('userId', result.$id)], + ); + + await databases.updateDocument( + appwriteConfig.databaseId, + Collection.USERS, + userDocument.documents[0].$id, + { + email: email, + name: userDocument.documents[0].name, + labels: userDocument.documents[0].labels, + userId: userDocument.documents[0].userId, + leagues: userDocument.documents[0].leagues, + }, + ); + } catch (error) { + throw error; + } +} + /** * Get the current user * @param userId - The user ID diff --git a/app/(main)/account/settings/page.test.tsx b/app/(main)/account/settings/page.test.tsx new file mode 100644 index 00000000..75716f33 --- /dev/null +++ b/app/(main)/account/settings/page.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { useAuthContext } from '@/context/AuthContextProvider'; +import AccountSettings from './page'; + +jest.mock('@/context/AuthContextProvider'); + +describe('Account Settings Page', () => { + let mockUseAuthContext: jest.Mock; + + beforeEach(() => { + mockUseAuthContext = jest.fn(); + (useAuthContext as jest.Mock).mockImplementation(mockUseAuthContext); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should display GlobalSpinner while loading data', async () => { + mockUseAuthContext.mockReturnValue({ + isSignedIn: false, + }); + render(); + + await waitFor(() => { + expect(screen.getByTestId('global-spinner')).toBeInTheDocument(); + }); + }); + + it('should not show GlobalSpinner and render the settings page', async () => { + mockUseAuthContext.mockReturnValue({ + isSignedIn: true, + }); + + render(); + + expect(screen.getByTestId('settings-page-header')).toBeInTheDocument(); + expect(screen.getByTestId('email')).toBeInTheDocument(); + expect(screen.getByTestId('current-password')).toBeInTheDocument(); + expect(screen.getByTestId('old-password')).toBeInTheDocument(); + expect(screen.getByTestId('new-password')).toBeInTheDocument(); + + expect(screen.queryByTestId('global-spinner')).not.toBeInTheDocument(); + }); +}); diff --git a/app/(main)/account/settings/page.tsx b/app/(main)/account/settings/page.tsx new file mode 100644 index 00000000..29777fe8 --- /dev/null +++ b/app/(main)/account/settings/page.tsx @@ -0,0 +1,66 @@ +// Copyright (c) Gridiron Survivor. +// Licensed under the MIT License. + +'use client'; +import GlobalSpinner from '@/components/GlobalSpinner/GlobalSpinner'; +import Heading from '@/components/Heading/Heading'; +import LinkCustom from '@/components/LinkCustom/LinkCustom'; +import ResetPasswordForm from '@/components/RestPasswordForm/ResetPasswordForm'; +import UpdateEmailForm from '@/components/UpdateEmailForm/UpdateEmailForm'; +import { useAuthContext } from '@/context/AuthContextProvider'; +import { ChevronLeft } from 'lucide-react'; +import { JSX, useEffect, useState } from 'react'; + +/** + * Display user preferences + * @returns {JSX.Element} The rendered user preferences component. + */ +const AccountSettings = (): JSX.Element => { + const [loadingData, setLoadingData] = useState(true); + const { isSignedIn } = useAuthContext(); + + useEffect(() => { + if (isSignedIn) { + setLoadingData(false); + } + }, [isSignedIn]); + + return ( + <> + {loadingData ? ( + + ) : ( +
+
+
+ + + Your Leagues + +
+ + Settings + +
+ +
+ + +
+
+ )} + + ); +}; + +export default AccountSettings; diff --git a/components/Alert/Alert.tsx b/components/Alert/Alert.tsx index 80fc809f..fff2d4b8 100644 --- a/components/Alert/Alert.tsx +++ b/components/Alert/Alert.tsx @@ -9,13 +9,13 @@ const alertVariants = cva( { variants: { variant: { - default: 'bg-background text-foreground', + default: 'bg-muted text-muted-foreground', error: - 'bg-background text-error [&>svg]:text-error text-error border-error', + 'bg-muted text-error [&>svg]:text-error text-error border-error', warning: - 'bg-background text-warning [&>svg]:text-warning text-warning border-warning', + 'bg-muted text-warning [&>svg]:text-warning text-warning border-warning', success: - 'bg-background text-success [&>svg]:text-success text-success border-success', + 'bg-muted text-success [&>svg]:text-success text-success border-success', }, }, defaultVariants: { diff --git a/components/AlertNotification/AlertNotification.test.tsx b/components/AlertNotification/AlertNotification.test.tsx index 0d307594..39555f5e 100644 --- a/components/AlertNotification/AlertNotification.test.tsx +++ b/components/AlertNotification/AlertNotification.test.tsx @@ -1,8 +1,9 @@ -import React from 'react'; -import { render } from '@testing-library/react'; import Alert from './AlertNotification'; import { AlertVariants } from './Alerts.enum'; import { CheckCircle, XCircle, Info, AlertTriangle } from 'lucide-react'; +import React from 'react'; +import { render, screen, fireEvent} from '@testing-library/react'; +import toast from 'react-hot-toast'; const variantTestCases = { [AlertVariants.Success]: { @@ -30,7 +31,36 @@ const variantTestCases = { describe('AlertNotification', () => { for (const [key, value] of Object.entries(variantTestCases)) { it(`renders the correct variant ${key}`, () => { - render(); + render( + , + ); + }); + + it('should render the dismiss button on each alert type', () => { + render( + , + ); + const dismissButton = screen.getByTestId('dismiss-alert-btn'); + expect(dismissButton).toBeInTheDocument(); + }); + + it('should fire the toast.remove() function when dismiss button is clicked', async () => { + const spyToast = jest.spyOn(toast, 'remove'); + render( + , + ); + const dismissButton = screen.getByTestId('dismiss-alert-btn'); + fireEvent.click(dismissButton); + expect(spyToast).toHaveBeenCalled(); }); - } + }; }); diff --git a/components/AlertNotification/AlertNotification.tsx b/components/AlertNotification/AlertNotification.tsx index fb07b49b..95020f32 100644 --- a/components/AlertNotification/AlertNotification.tsx +++ b/components/AlertNotification/AlertNotification.tsx @@ -2,12 +2,14 @@ // Licensed under the MIT License. import { Alert as AlertDefault } from '../Alert/Alert'; -import { AlertTitle } from '../AlertTItle/AlertTitle'; import { AlertDescription } from '../AlertDescription/AlertDescription'; -import { JSX } from 'react'; -import { CheckCircle, XCircle, Info, AlertTriangle } from 'lucide-react'; -import { IAlertNotification } from './AlertNotification.interface'; +import { AlertTitle } from '../AlertTItle/AlertTitle'; import { AlertVariants } from './Alerts.enum'; +import { Button } from '../Button/Button'; +import { CheckCircle, XCircle, Info, AlertTriangle, X } from 'lucide-react'; +import { IAlertNotification } from './AlertNotification.interface'; +import { JSX } from 'react'; +import toast from 'react-hot-toast'; const variantConfig = { success: { @@ -46,6 +48,15 @@ const Alert = ({ + + + + + ); +}; + +export default ResetPasswordForm; diff --git a/components/UpdateEmailForm/UpdateEmailForm.test.tsx b/components/UpdateEmailForm/UpdateEmailForm.test.tsx new file mode 100644 index 00000000..8890cd1d --- /dev/null +++ b/components/UpdateEmailForm/UpdateEmailForm.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { toast } from 'react-hot-toast'; +import UpdateEmailForm from './UpdateEmailForm'; +import Alert from '@/components/AlertNotification/AlertNotification'; +import { AlertVariants } from '@/components/AlertNotification/Alerts.enum'; +import { updateUserEmail } from '@/api/apiFunctions'; + +jest.mock('react-hot-toast'); +jest.mock('@/api/apiFunctions'); +jest.mock(`@/store/dataStore`, () => ({ + useDataStore: jest.fn(() => ({ + user: { documentId: 'doc123', id: '123', email: 'olduser@example.com' }, + updateUser: jest.fn(), + })), +})); + +describe('UpdateEmailForm', () => { + let mockToast: jest.Mock; + + beforeEach(() => { + mockToast = jest.fn(); + (toast.custom as jest.Mock).mockImplementation(mockToast); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const fillForm = () => { + fireEvent.change(screen.getByTestId('email'), { + target: { value: 'newuser@example.com' }, + }); + fireEvent.change(screen.getByTestId('current-password'), { + target: { value: 'password123' }, + }); + }; + + it('should render the update email form with the update button disabled', () => { + render(); + + expect(screen.getByTestId('email')).toBeInTheDocument(); + expect(screen.getByTestId('current-password')).toBeInTheDocument(); + expect(screen.getByTestId('updated-email-button')).toBeInTheDocument(); + expect(screen.getByTestId('updated-email-button')).toBeDisabled(); + }); + + it('should update the form with new email and password and update button should be enabled', async () => { + render(); + fillForm(); + + await waitFor(() => { + expect(screen.getByTestId('email')).toHaveValue('newuser@example.com'); + expect(screen.getByTestId('current-password')).toHaveValue('password123'); + expect(screen.getByTestId('updated-email-button')).not.toBeDisabled(); + }); + }); + it('should submit the form and update the user email successfully', async () => { + render(); + fillForm(); + + fireEvent.click(screen.getByTestId('updated-email-button')); + + await waitFor(() => { + expect(updateUserEmail).toHaveBeenCalledWith({ + email: 'newuser@example.com', + password: 'password123', + }); + expect(screen.getByTestId('email')).toHaveValue('newuser@example.com'); + expect(screen.getByTestId('current-password')).toHaveValue(''); + + expect(toast.custom).toHaveBeenCalledWith( + , + ); + }); + }); + it('should submit the form and show error message on email update failure', async () => { + (updateUserEmail as jest.Mock).mockRejectedValue(new Error()); + + render(); + fillForm(); + + fireEvent.click(screen.getByTestId('updated-email-button')); + + await waitFor(() => { + expect(updateUserEmail).rejects.toThrow(); + + expect(toast.custom).toHaveBeenCalledWith( + , + ); + }); + }); +}); diff --git a/components/UpdateEmailForm/UpdateEmailForm.tsx b/components/UpdateEmailForm/UpdateEmailForm.tsx new file mode 100644 index 00000000..9348519c --- /dev/null +++ b/components/UpdateEmailForm/UpdateEmailForm.tsx @@ -0,0 +1,181 @@ +// Copyright (c) Gridiron Survivor. +// Licensed under the MIT License. + +'use client'; +import { updateUserEmail } from '@/api/apiFunctions'; +import Alert from '@/components/AlertNotification/AlertNotification'; +import { AlertVariants } from '@/components/AlertNotification/Alerts.enum'; +import { Button } from '@/components/Button/Button'; +import { Input } from '@/components/Input/Input'; +import LoadingSpinner from '@/components/LoadingSpinner/LoadingSpinner'; +import { useDataStore } from '@/store/dataStore'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { JSX, useState } from 'react'; +import { Control, SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import toast from 'react-hot-toast'; +import { z } from 'zod'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../Form/Form'; + +const UpdateEmailSchema = z.object({ + email: z + .string() + .min(1, { message: 'Please enter an email address' }) + .email({ message: 'Please enter a valid email address' }), + password: z.string().min(1, { message: 'Please enter a password' }), +}); + +type UpdateEmailSchemaType = z.infer; + +/** + * Display update user email form + * @returns {JSX.Element} The rendered update user email form. + */ +const UpdateEmailForm = (): JSX.Element => { + const [isUpdating, setIsUpdating] = useState(false); + const { user, updateUser } = useDataStore((state) => state); + + const form = useForm({ + resolver: zodResolver(UpdateEmailSchema), + defaultValues: { + email: user.email, + password: '', + }, + }); + + const email: string = useWatch({ + control: form.control, + name: 'email', + }); + + const password: string = useWatch({ + control: form.control, + name: 'password', + }); + + /** + * A function that handles form submission. + * @param {UpdateEmailSchemaType} data - The data submitted in the form. + * @returns {Promise} Promise that resolves after form submission is processed. + */ + const onSubmit: SubmitHandler = async ( + data: UpdateEmailSchemaType, + ): Promise => { + const { email, password } = data; + setIsUpdating(true); + try { + await updateUserEmail({ + email, + password, + }); + + toast.custom( + , + ); + + updateUser(user.documentId, user.id, email, user.leagues); + form.reset({ email: email || '', password: '' }); + } catch (error) { + console.error('Email Update Failed', error); + toast.custom( + , + ); + } finally { + setIsUpdating(false); + } + }; + + const isDisabled = email === user.email || !email || isUpdating || !password; + + return ( +
+ +
+
+

Email

+

+ This will update both your login email and where you receive email + updates from GridIron Survivor. +

+
+
+ } + name="email" + render={({ field }) => ( + + + Email + + + + + {form.formState.errors.email && ( + + {form.formState.errors.email.message} + + )} + + )} + /> + } + name="password" + render={({ field }) => ( + + + Current Password + + + + + {form.formState.errors.password && ( + + {form.formState.errors.password.message} + + )} + + )} + /> +
+
+
+ +
+
+ + ); +}; + +export default UpdateEmailForm; diff --git a/package.json b/package.json index bf8fd0c9..b7f8dbc8 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "clsx": "^2.1.0", "geist": "^1.2.2", "immer": "^10.1.1", - "lucide-react": "^0.312.0", + "lucide-react": "^0.447.0", "next": "^14.1.1", "node-appwrite": "^13.0.0", "postcss": "8.4.29", @@ -34,6 +34,7 @@ "react-dom": "18.2.0", "react-hook-form": "^7.51.3", "react-hot-toast": "^2.4.1", + "react-icons": "^5.3.0", "tailwind-merge": "^2.2.2", "tailwindcss": "3.3.3", "tailwindcss-animate": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b34ba8bc..22e5ef82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ importers: specifier: ^10.1.1 version: 10.1.1 lucide-react: - specifier: ^0.312.0 - version: 0.312.0(react@18.2.0) + specifier: ^0.447.0 + version: 0.447.0(react@18.2.0) next: specifier: ^14.1.1 version: 14.2.3(@babel/core@7.24.7)(@playwright/test@1.44.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -71,6 +71,9 @@ importers: react-hot-toast: specifier: ^2.4.1 version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-icons: + specifier: ^5.3.0 + version: 5.3.0(react@18.2.0) tailwind-merge: specifier: ^2.2.2 version: 2.3.0 @@ -4890,10 +4893,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.312.0: - resolution: {integrity: sha512-3UZsqyswRXjW4t+nw+InICewSimjPKHuSxiFYqTshv9xkK3tPPntXk/lvXc9pKlXIxm3v9WKyoxcrB6YHhP+dg==} + lucide-react@0.447.0: + resolution: {integrity: sha512-SZ//hQmvi+kDKrNepArVkYK7/jfeZ5uFNEnYmd45RKZcbGD78KLnrcNXmgeg6m+xNHFvTG+CblszXCy4n6DN4w==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} @@ -5719,6 +5722,11 @@ packages: react: '>=16' react-dom: '>=16' + react-icons@5.3.0: + resolution: {integrity: sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==} + peerDependencies: + react: '*' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -12981,7 +12989,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.312.0(react@18.2.0): + lucide-react@0.447.0(react@18.2.0): dependencies: react: 18.2.0 @@ -13777,6 +13785,10 @@ snapshots: transitivePeerDependencies: - csstype + react-icons@5.3.0(react@18.2.0): + dependencies: + react: 18.2.0 + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/public/assets/appwriteLogo.svg b/public/assets/appwriteLogo.svg new file mode 100644 index 00000000..6b35a520 --- /dev/null +++ b/public/assets/appwriteLogo.svg @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/public/assets/blogRecorderLogo.svg b/public/assets/blogRecorderLogo.svg new file mode 100644 index 00000000..94685af2 --- /dev/null +++ b/public/assets/blogRecorderLogo.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/fonts/Geist[wght].ttf b/public/assets/fonts/Geist[wght].ttf new file mode 100644 index 00000000..e95c2195 Binary files /dev/null and b/public/assets/fonts/Geist[wght].ttf differ diff --git a/public/assets/frontendMentorLogo.svg b/public/assets/frontendMentorLogo.svg new file mode 100644 index 00000000..ad940756 --- /dev/null +++ b/public/assets/frontendMentorLogo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/public/assets/gitkrakenLogo.svg b/public/assets/gitkrakenLogo.svg new file mode 100644 index 00000000..9faca53c --- /dev/null +++ b/public/assets/gitkrakenLogo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/pastelLogo.svg b/public/assets/pastelLogo.svg new file mode 100644 index 00000000..92647f4b --- /dev/null +++ b/public/assets/pastelLogo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/stories/Sponsors.tsx b/stories/Sponsors.tsx new file mode 100644 index 00000000..7872ff22 --- /dev/null +++ b/stories/Sponsors.tsx @@ -0,0 +1,74 @@ +// Copyright (c) Gridiron Survivor. +// Licensed under the MIT License. + +import React from 'react'; +import Image from 'next/image'; + +type Sponsor = { + name: string; + logoPath: string; + website: string; +}; + +const SponsorsArray: Sponsor[] = [ + { + name: 'Appwrite', + logoPath: '/assets/appwriteLogo.svg', + website: 'https://appwrite.io/', + }, + { + name: 'Blog Recorder', + logoPath: '/assets/blogRecorderLogo.svg', + website: 'https://blogrecorder.com/', + }, + { + name: 'Frontend Mentor', + logoPath: '/assets/frontendMentorLogo.svg', + website: 'https://www.frontendmentor.io/', + }, + { + name: 'GitKraken', + logoPath: '/assets/gitkrakenLogo.svg', + website: 'https://www.gitkraken.com/', + }, + { + name: 'Pastel', + logoPath: '/assets/pastelLogo.svg', + website: 'https://usepastel.com/', + }, +]; + +/** + * Creates the Sponsors section of the Storybook Welcome page. + * @returns the Sponsors section + */ +const Sponsors = (): React.ReactElement => { + return ( +
+

Sponsors

+
+ {SponsorsArray.map((sponsor) => ( + + ))} +
+
+ ); +}; + +export default Sponsors; diff --git a/stories/TeamMemberInfo.tsx b/stories/TeamMemberInfo.tsx new file mode 100644 index 00000000..9b22c65b --- /dev/null +++ b/stories/TeamMemberInfo.tsx @@ -0,0 +1,65 @@ +// Copyright (c) Gridiron Survivor. +// Licensed under the MIT License. + +import React from 'react'; +import { FaLinkedin, FaXTwitter, FaGithub } from 'react-icons/fa6'; +import { TeamMember } from './TeamMemberSection'; + +/** + * Renders social media links for a team member. + * @param {TeamMember} props - The properties of a team member. + * @param [props.github] - The GitHub profile URL of the team member. + * @param [props.linkedin] - The LinkedIn profile URL of the team member. + * @param [props.twitter] - The Twitter profile URL of the team member. + * @returns {React.ReactElement} A React element containing social media links. + */ +const TeamMemberInfo = ({ + name, + role, + github, + linkedin, + twitter, +}: TeamMember): React.ReactElement => { + return ( + <> +
+

{name}

+

{role}

+
+
+ {github && ( + + + + )} + {linkedin && ( + + + + )} + {twitter && ( + + + + )} +
+ + ); +}; + +export default TeamMemberInfo; diff --git a/stories/TeamMemberSection.tsx b/stories/TeamMemberSection.tsx new file mode 100644 index 00000000..551e6044 --- /dev/null +++ b/stories/TeamMemberSection.tsx @@ -0,0 +1,123 @@ +// Copyright (c) Gridiron Survivor. +// Licensed under the MIT License. + +import React from 'react'; +import TeamMemberInfo from './TeamMemberInfo'; + +export type TeamMember = { + name: string; + role: string; + github?: string; + linkedin?: string; + twitter?: string; +}; + +export const TeamMembersArray: TeamMember[] = [ + { + name: 'Shashi Lo', + role: 'Engineering Manager', + github: 'https://github.com/shashilo', + linkedin: 'https://www.linkedin.com/in/shashilo/', + twitter: 'https://x.com/shashiwhocodes', + }, + { + name: 'Alex Appleget', + role: 'Software Engineer', + github: 'https://github.com/alexappleget', + linkedin: 'https://www.linkedin.com/in/alex-appleget/', + twitter: 'https://x.com/alexlikescoding', + }, + { + name: 'Richard Choi', + role: 'Developer Relations Engineer', + github: 'https://github.com/choir27', + linkedin: 'https://www.linkedin.com/in/richard-choir/', + twitter: 'https://x.com/choir241', + }, + { + name: 'Cody Epstein', + role: 'UX Engineer', + github: 'https://github.com/kepsteen/', + linkedin: 'https://www.linkedin.com/in/cody-epstein/', + twitter: '', + }, + { + name: 'Ryan Furrer', + role: 'UX Engineer', + github: 'https://github.com/ryandotfurrer', + linkedin: 'https://www.linkedin.com/in/ryanfurrer/', + twitter: 'https://x.com/ryandotfurrer', + }, + { + name: 'Walter Furrer', + role: 'Documentation Engineer', + github: 'https://github.com/FurrerW', + linkedin: 'https://www.linkedin.com/in/furrerw/', + twitter: 'https://x.com/furrerw', + }, + { + name: 'Michael Larocca', + role: 'Documentation Engineer', + github: 'https://github.com/MichaelLarocca', + linkedin: 'https://www.linkedin.com/in/michaeljudelarocca/', + twitter: 'https://x.com/MikeJudeLarocca', + }, + { + name: 'Danielle Lindblom', + role: 'Frontend Engineer', + github: 'https://github.com/Danielle254', + linkedin: 'https://www.linkedin.com/in/danielle-lindblom/', + twitter: '', + }, + { + name: 'Dominick Monaco', + role: 'Frontend Engineer', + github: 'https://github.com/HoldUpFjord', + linkedin: 'https://www.linkedin.com/in/dominick-j-monaco/', + twitter: 'https://x.com/DominickJMonaco', + }, + { + name: 'Corina Murg', + role: 'Accessibility Specialist', + github: 'https://github.com/CorinaMurg', + linkedin: 'https://www.linkedin.com/in/corinamurg/', + twitter: 'https://x.com/CorinaMurg', + }, + { + name: 'Mai Vang', + role: 'Documentation Engineer', + github: 'https://github.com/vmaineng', + linkedin: 'https://www.linkedin.com/in/mai-vang-swe/', + twitter: 'https://x.com/MaiVangSWE', + }, +]; + +/** + * Creates the Team Member section of the Storybook Welcome Page + * @returns the Team Member section + */ +const TeamMemberSection = (): React.ReactElement => { + return ( +
+

Meet the Team

+
+ {TeamMembersArray.map((member) => ( +
+ +
+ ))} +
+
+ ); +}; + +export default TeamMemberSection; diff --git a/stories/Welcome.mdx b/stories/Welcome.mdx index 2e6f91bd..6cd34cb8 100644 --- a/stories/Welcome.mdx +++ b/stories/Welcome.mdx @@ -1,7 +1,37 @@ import './styles.css'; +import Image from 'next/image'; +import gridironLogo from '../public/assets/logo-colored-outline.svg'; +import TeamMemberSection from './TeamMemberSection'; +import Sponsors from './Sponsors'; +import { Unstyled } from '@storybook/blocks'; -

Welcome

+ +
+Gridiron Survivor Logo -
-

[Application Setup](?path=/docs/about-application-setup--docs)

+
+ +# What is Gridiron Survivor? + +Gridiron Survivor is the ultimate survivor league app. Track your picks, see how you stack up against your competition and hope to be crowned champion! Developed by a talented team of Junior Developers led by Senior Software Engineer Shashi Lo, Gridiron Survivor is the perfect app for any football fan looking to take their survivor league to the next level. + +
    +
  1. [View our GitHub](https://github.com/LetsGetTechnical/gridiron-survivor)
  2. +
  3. [Check out Gridiron Survivor](https://www.gridironsurvivor.com)
  4. +
+
+ + + + + +
    +
  1. +
  2. [Application Setup](?path=/docs/about-application-setup--docs)
  3. +
+ diff --git a/stories/styles.css b/stories/styles.css index c1aa8f90..8bc8ce8a 100644 --- a/stories/styles.css +++ b/stories/styles.css @@ -1,3 +1,65 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +@import url('../app/globals.css'); + +* { + font-family: Geist Sans; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + @apply text-foreground; + @apply font-bold; + @apply tracking-tight; + text-wrap: balance; +} + +h1 { + @apply text-5xl; +} + +h2 { + @apply text-4xl; +} + +h3 { + @apply text-3xl; +} + +h4 { + @apply text-2xl; +} + +h5 { + @apply text-xl; +} + +h6 { + @apply text-lg; +} + +p { + @apply text-base; + @apply tracking-normal; + @apply text-muted-foreground; + text-wrap: balance; +} + +header h1 { + @apply pt-24; + @apply pb-8; +} + +header p { + @apply text-lg; + @apply pb-8; +} + .doc_nav_links { display: flex; justify-content: space-between; diff --git a/tailwind.config.js b/tailwind.config.js index 3d6cef2c..58a3f98f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -3,7 +3,9 @@ module.exports = { content: [ + './app/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}', + './stories/*.{js,ts,jsx,tsx,mdx}', './stories/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', ],