diff --git a/.env.example b/.env.example index c28a9263..e89bd8d4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ NEXT_PUBLIC_ACM_API_URL="https://api.acmucsd.com/api/v2" NEXT_PUBLIC_KLEFKI_API_URL="" -NEXT_PUBLIC_TOTP_KEY="" \ No newline at end of file +NEXT_PUBLIC_TOTP_KEY="" + +# Set this environment variable to any string if you're connecting +# to the production API and want to use production file size limits. +# process.env.NEXT_PUBLIC_PRODUCTION \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 9b07d282..74ecd12f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -38,5 +38,10 @@ } ] }, - "ignorePatterns": "src/**/*.d.ts" + "ignorePatterns": [ + "src/**/*.d.ts", + "**/public/sw.js", + "**/public/workbox-*.js", + "**/public/worker-*.js" + ] } diff --git a/.gitignore b/.gitignore index b947a678..885edd4c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,11 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# PWA files +**/public/sw.js +**/public/workbox-*.js +**/public/worker-*.js +**/public/sw.js.map +**/public/workbox-*.js.map +**/public/worker-*.js.map \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 6937cbbe..49c6642d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,17 +3,15 @@ "editor.formatOnSave": true, "typescript.preferences.importModuleSpecifier": "non-relative", "editor.codeActionsOnSave": { - "source.fixAll": true, - "source.organizeImports": true + "source.fixAll": "explicit", + "source.organizeImports": "explicit" }, "css.validate": false, "less.validate": false, "scss.validate": false, - "stylelint.validate": [ - "scss" - ], + "stylelint.validate": ["scss"], "files.associations": { "*.env.development": "env", "*.env.production": "env" } -} \ No newline at end of file +} diff --git a/cypress/e2e/pages/edit-profile.cy.ts b/cypress/e2e/pages/edit-profile.cy.ts new file mode 100644 index 00000000..374d5678 --- /dev/null +++ b/cypress/e2e/pages/edit-profile.cy.ts @@ -0,0 +1,35 @@ +/// + +describe('Edit Profile Page', () => { + beforeEach(() => { + cy.login('standard'); + cy.location('pathname').should('equal', '/'); + cy.visit('/profile/edit'); + cy.location('pathname').should('equal', '/profile/edit'); + }); + + it('Should update profile preview', () => { + // write something new to make sure it can save + cy.typeInForm('First', new Date().toISOString()); + cy.get('button').contains('Save').click(); + + cy.fixture('profile').then(profile => { + const { first, last, bio, major, year } = profile; + + cy.typeInForm('First', first); + cy.typeInForm('Last', last); + cy.selectInForm('Major', major); + cy.selectInForm('Graduation Year', year); + cy.typeInForm('Biography', bio); + cy.get('button').contains('Save').click(); + + cy.get('.Toastify').contains('Changes saved!').should('exist'); + + cy.get('section:contains("Current Profile")').within(() => { + Object.values(profile).forEach((value: string | number) => { + cy.contains(value).should('exist'); + }); + }); + }); + }); +}); diff --git a/cypress/fixtures/profile.json b/cypress/fixtures/profile.json new file mode 100644 index 00000000..5f6df697 --- /dev/null +++ b/cypress/fixtures/profile.json @@ -0,0 +1,7 @@ +{ + "first": "John", + "last": "Doe", + "bio": "I am a testing account.", + "major": "Computer Engineering", + "year": "2026" +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index cb0ff5c3..a3473cb6 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1 +1,50 @@ +declare global { + namespace Cypress { + interface Chainable { + /** + * Log in as the specified user, pulling details from accounts.json + * @example cy.login('standard') + */ + login(account: string): Chainable; + + /** + * Type text into a form input or textarea under specified label + * @param label - text of label that the input is a child of + * @param value - text to be typed into input + */ + typeInForm(label: string, value: string): Chainable; + + /** + * Select the given option in a select under specified label + * @param label - text of label that the select is a child of + * @param value - option to be selected + */ + selectInForm(label: string, value: string): Chainable; + } + } +} + +Cypress.Commands.add('login', (account: string) => { + cy.fixture('accounts.json').then(accs => { + if (!(account in accs)) + throw new Error(`Account '${account}' isn't specified in \`accounts.json\``); + const { email, password } = accs[account]; + + cy.visit('/login'); + cy.get('input[name="email"]').type(email); + cy.get('input[name="password"]').type(password); + cy.get('button').contains('Sign In').click(); + }); +}); + +Cypress.Commands.add('typeInForm', (label: string, value: string) => { + cy.get(`label:contains("${label}") input, label:contains("${label}") textarea`).as('input'); + cy.get('@input').clear(); + cy.get('@input').type(value as string); +}); + +Cypress.Commands.add('selectInForm', (label: string, value: string | number) => { + cy.get(`label:contains("${label}") select`).select(value); +}); + export {}; diff --git a/next.config.js b/next.config.js index 704d5d37..644ed45c 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,31 @@ const env = process.env.NODE_ENV; const isDevelopment = env !== 'production'; +const runtimeCaching = require('next-pwa/cache'); + +// By default, next-pwa serves from the cache while updating the cache with a +// network request. This results in faster page loads, but the data will be +// stale, most notable when checking into an event (membership points will be +// out of date) or enabling "Preview store as member" on the admin page. Note +// that this only applies to static routes (e.g. /store but not /u/[uuid]). +// +// This makes it fetch data from the server (then falling back to the cache if +// the user is offline). This will result in slower page loads, but the data +// will always be up-to-date. +// https://github.com/vercel/next.js/discussions/52024#discussioncomment-6325542 +const nextData = runtimeCaching.find(entry => entry.options.cacheName === 'next-data'); +if (nextData) { + nextData.handler = 'NetworkFirst'; +} + +const withPWA = require('next-pwa')({ + dest: 'public', + runtimeCaching, + register: true, + skipWaiting: true, + disable: isDevelopment, +}); + /** @type {import('next').NextConfig} */ const nextConfig = { eslint: { @@ -37,4 +62,4 @@ const nextConfig = { }, }; -module.exports = nextConfig; +module.exports = withPWA(nextConfig); diff --git a/package.json b/package.json index d3480b4e..b987857b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "packageManager": "yarn@1.22.19", "engines": { - "node": ">=18", + "node": "18.x", "yarn": ">=1.22", "npm": "use-yarn-not-npm" }, @@ -36,8 +36,10 @@ "ics": "^3.7.2", "luxon": "^3.3.0", "next": "^13.2.5-canary.30", + "next-pwa": "^5.6.0", "next-themes": "^0.2.1", "react": "18.2.0", + "react-confetti": "^6.1.0", "react-dom": "18.2.0", "react-hook-form": "^7.43.0", "react-icons": "^4.4.0", diff --git a/public/assets/graphics/portal/diamond-friends.svg b/public/assets/graphics/portal/diamond-friends.svg new file mode 100644 index 00000000..4fdd2419 --- /dev/null +++ b/public/assets/graphics/portal/diamond-friends.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/graphics/portal/raccoon-hero.svg b/public/assets/graphics/portal/raccoon-hero.svg index 80791128..6355261b 100644 --- a/public/assets/graphics/portal/raccoon-hero.svg +++ b/public/assets/graphics/portal/raccoon-hero.svg @@ -1,138 +1,138 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/public/assets/graphics/portal/treasure.svg b/public/assets/graphics/portal/treasure.svg new file mode 100644 index 00000000..4eb1ec7c --- /dev/null +++ b/public/assets/graphics/portal/treasure.svg @@ -0,0 +1,329 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/graphics/portal/waves.svg b/public/assets/graphics/portal/waves.svg index 1cfea445..28be609b 100644 --- a/public/assets/graphics/portal/waves.svg +++ b/public/assets/graphics/portal/waves.svg @@ -1,16 +1,16 @@ - + - - - - - - - + + + + + + + diff --git a/public/assets/icons/arrow-left.svg b/public/assets/icons/arrow-left.svg index 16b35dea..f8ff17a2 100644 --- a/public/assets/icons/arrow-left.svg +++ b/public/assets/icons/arrow-left.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/assets/icons/arrow-right.svg b/public/assets/icons/arrow-right.svg index e9746fd6..820e9c21 100644 --- a/public/assets/icons/arrow-right.svg +++ b/public/assets/icons/arrow-right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/assets/icons/close-icon.svg b/public/assets/icons/close-icon.svg index bd525ea0..4a235b44 100644 --- a/public/assets/icons/close-icon.svg +++ b/public/assets/icons/close-icon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/assets/icons/edit.svg b/public/assets/icons/edit.svg new file mode 100644 index 00000000..fc3c1e6a --- /dev/null +++ b/public/assets/icons/edit.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/icons/email-solid.svg b/public/assets/icons/email-solid.svg new file mode 100644 index 00000000..a0caf3fd --- /dev/null +++ b/public/assets/icons/email-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/empty-cart.svg b/public/assets/icons/empty-cart.svg new file mode 100644 index 00000000..1bf192f3 --- /dev/null +++ b/public/assets/icons/empty-cart.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/assets/icons/facebook-icon.svg b/public/assets/icons/facebook-icon.svg index d3f40bca..02c27646 100644 --- a/public/assets/icons/facebook-icon.svg +++ b/public/assets/icons/facebook-icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/assets/icons/github-icon.svg b/public/assets/icons/github-icon.svg new file mode 100644 index 00000000..458be3ac --- /dev/null +++ b/public/assets/icons/github-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/grad-cap-icon.svg b/public/assets/icons/grad-cap-icon.svg new file mode 100644 index 00000000..629a8a6d --- /dev/null +++ b/public/assets/icons/grad-cap-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/icons/ig-icon.svg b/public/assets/icons/ig-icon.svg deleted file mode 100644 index 6fcb443d..00000000 --- a/public/assets/icons/ig-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/assets/icons/instagram.svg b/public/assets/icons/instagram.svg index c0985960..2e0ecf50 100644 --- a/public/assets/icons/instagram.svg +++ b/public/assets/icons/instagram.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/public/assets/icons/leaderboard-icon.svg b/public/assets/icons/leaderboard-icon.svg index b8bdc0a4..5b3897e0 100644 --- a/public/assets/icons/leaderboard-icon.svg +++ b/public/assets/icons/leaderboard-icon.svg @@ -1,4 +1,4 @@ - + diff --git a/public/assets/icons/link-icon.svg b/public/assets/icons/link-icon.svg new file mode 100644 index 00000000..ea1335c6 --- /dev/null +++ b/public/assets/icons/link-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/linkedin-icon.svg b/public/assets/icons/linkedin-icon.svg new file mode 100644 index 00000000..cb13cc42 --- /dev/null +++ b/public/assets/icons/linkedin-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/assets/icons/major-icon.svg b/public/assets/icons/major-icon.svg index 64e55af9..224c42df 100644 --- a/public/assets/icons/major-icon.svg +++ b/public/assets/icons/major-icon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/assets/icons/profile-icon.svg b/public/assets/icons/profile-icon.svg index 65a36ab4..3164f29e 100644 --- a/public/assets/icons/profile-icon.svg +++ b/public/assets/icons/profile-icon.svg @@ -1,3 +1,3 @@ - + diff --git a/public/assets/icons/twitter.svg b/public/assets/icons/twitter.svg new file mode 100644 index 00000000..6ec13e8b --- /dev/null +++ b/public/assets/icons/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 00000000..749be3be Binary files /dev/null and b/public/icon-192x192.png differ diff --git a/public/icon-256x256.png b/public/icon-256x256.png new file mode 100644 index 00000000..fe18f759 Binary files /dev/null and b/public/icon-256x256.png differ diff --git a/public/icon-384x384.png b/public/icon-384x384.png new file mode 100644 index 00000000..f7ff182a Binary files /dev/null and b/public/icon-384x384.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 00000000..1c7ad55d Binary files /dev/null and b/public/icon-512x512.png differ diff --git a/public/manifest.json b/public/manifest.json index 95e7adcc..3566a7f2 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,15 +1,32 @@ { - "short_name": "ACM Membership Portal", - "name": "Association for Computing Machinery at UC San Diego Student Membership Portal", + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone", + "scope": "/", + "start_url": "/", + "name": "ACM at UCSD Membership Portal", + "short_name": "ACM Portal", + "description": "Association for Computing Machinery at UC San Diego Student Membership Portal", "icons": [ { - "src": "favicon.ico", - "sizes": "64x64 32x32 16x16", - "type": "image/x-icon" + "src": "/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "/icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" } - ], - "start_url": ".", - "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" + ] } diff --git a/src/components/admin/event/EventDetailsFormItem/index.tsx b/src/components/admin/DetailsFormItem/index.tsx similarity index 100% rename from src/components/admin/event/EventDetailsFormItem/index.tsx rename to src/components/admin/DetailsFormItem/index.tsx diff --git a/src/components/admin/event/EventDetailsFormItem/style.module.scss b/src/components/admin/DetailsFormItem/style.module.scss similarity index 95% rename from src/components/admin/event/EventDetailsFormItem/style.module.scss rename to src/components/admin/DetailsFormItem/style.module.scss index d8b4f7bf..52fcccef 100644 --- a/src/components/admin/event/EventDetailsFormItem/style.module.scss +++ b/src/components/admin/DetailsFormItem/style.module.scss @@ -11,7 +11,7 @@ } } - input, + input:not([type='checkbox']), textarea, select { border: 1px solid #bbb; diff --git a/src/components/admin/event/EventDetailsFormItem/style.module.scss.d.ts b/src/components/admin/DetailsFormItem/style.module.scss.d.ts similarity index 100% rename from src/components/admin/event/EventDetailsFormItem/style.module.scss.d.ts rename to src/components/admin/DetailsFormItem/style.module.scss.d.ts diff --git a/src/components/admin/event/AdminPickupEvent/AdminPickupEventForm.tsx b/src/components/admin/event/AdminPickupEvent/AdminPickupEventForm.tsx new file mode 100644 index 00000000..5fb61cf0 --- /dev/null +++ b/src/components/admin/event/AdminPickupEvent/AdminPickupEventForm.tsx @@ -0,0 +1,339 @@ +import DetailsFormItem from '@/components/admin/DetailsFormItem'; +import { Button } from '@/components/common'; +import { config, showToast } from '@/lib'; +import { AdminEventManager } from '@/lib/managers'; +import { UUID } from '@/lib/types'; +import { OrderPickupEvent } from '@/lib/types/apiRequests'; +import { PublicEvent, PublicOrderPickupEvent } from '@/lib/types/apiResponses'; +import { OrderPickupEventStatus } from '@/lib/types/enums'; +import { reportError } from '@/lib/utils'; +import { DateTime } from 'luxon'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import router from 'next/router'; +import { useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { BsArrowRight } from 'react-icons/bs'; +import style from './style.module.scss'; + +type FormValues = OrderPickupEvent; + +interface IProps { + mode: 'create' | 'edit'; + defaultData?: Partial; + token: string; + upcomingEvents: PublicEvent[]; +} + +const parseProps = (isoStart: string, isoEnd: string, rawOrderLimit: string) => { + const start = new Date(isoStart).toISOString(); + const end = new Date(isoEnd).toISOString(); + const orderLimit = parseInt(`${rawOrderLimit}`, 10); + return { start, end, orderLimit }; +}; + +export const completePickupEvent = async (uuid: UUID, token: string) => { + try { + await AdminEventManager.completePickupEvent({ + pickupEvent: uuid, + token: token, + }); + + showToast('Pickup Event Completed Successfully!', ''); + router.push(config.admin.store.pickup); + } catch (error) { + reportError('Could not complete pickup event', error); + } +}; + +export const cancelPickupEvent = async (uuid: UUID, token: string) => { + try { + await AdminEventManager.cancelPickupEvent({ + pickupEvent: uuid, + token: token, + }); + + showToast('Pickup Event Cancelled Successfully!', ''); + router.push(config.admin.store.pickup); + } catch (error) { + reportError('Could not cancel pickup event', error); + } +}; + +const AdminPickupEventForm = ({ mode, defaultData = {}, token, upcomingEvents }: IProps) => { + const router = useRouter(); + + const initialValues: FormValues = { + title: defaultData.title ?? '', + start: DateTime.fromISO(defaultData?.start ?? '').toFormat("yyyy-MM-dd'T'HH:mm"), + end: DateTime.fromISO(defaultData?.end ?? '').toFormat("yyyy-MM-dd'T'HH:mm"), + description: defaultData.description ?? '', + orderLimit: defaultData.orderLimit ?? 0, + linkedEventUuid: defaultData.linkedEvent?.uuid ?? null, + }; + + const { uuid, status } = defaultData; + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ defaultValues: initialValues }); + + const [loading, setLoading] = useState(false); + const resetForm = () => reset(initialValues); + + const createPickupEvent: SubmitHandler = async formData => { + setLoading(true); + + const { + title, + start: isoStart, + end: isoEnd, + linkedEventUuid, + description, + orderLimit: rawOrderLimit, + } = formData; + + try { + const { start, end, orderLimit } = parseProps(isoStart, isoEnd, `${rawOrderLimit}`); + + const uuid = await AdminEventManager.createPickupEvent(token, { + title, + start, + end, + description, + orderLimit, + linkedEventUuid: linkedEventUuid || null, + }); + showToast('Pickup Event created successfully!', '', [ + { + text: 'Continue editing', + onClick: () => router.push(`${config.admin.store.pickupEdit}/${uuid}`), + }, + ]); + router.push(`${config.admin.store.pickup}/${uuid}`); + } catch (error) { + if (error instanceof RangeError) { + reportError('Invalid date, could not create pickup event', error); + } else { + reportError('Could not create pickup event', error); + } + } finally { + setLoading(false); + } + }; + + const editPickupEvent: SubmitHandler = async formData => { + setLoading(true); + + const { + title, + start: isoStart, + end: isoEnd, + linkedEventUuid, + description, + orderLimit: rawOrderLimit, + } = formData; + + try { + const { start, end, orderLimit } = parseProps(isoStart, isoEnd, `${rawOrderLimit}`); + + await AdminEventManager.editPickupEvent({ + pickupEvent: { + title, + start, + end, + description, + orderLimit, + linkedEventUuid: linkedEventUuid || null, + }, + uuid: defaultData.uuid ?? '', + token: token, + + onSuccessCallback: event => { + setLoading(false); + showToast('Pickup Event Edit Successfully!', '', [ + { + text: 'View Live Pickup Event Page', + onClick: () => router.push(`${config.admin.store.pickup}/${event.uuid}`), + }, + ]); + }, + onFailCallback: error => { + setLoading(false); + reportError('Unable to edit pickup event', error); + }, + }); + } catch (error) { + if (error instanceof RangeError) { + reportError("Invalid date, can't save changes", error); + } else { + reportError('Could not save changes', error); + } + } finally { + setLoading(false); + } + }; + + const deletePickupEvent = async () => { + setLoading(true); + + try { + await AdminEventManager.deletePickupEvent({ + pickupEvent: defaultData.uuid ?? '', + token: token, + + onSuccessCallback: () => { + setLoading(false); + showToast('Pickup Event Deleted Successfully!', ''); + router.push(config.admin.store.pickup); + }, + onFailCallback: error => { + setLoading(false); + reportError('Unable to delete pickup event', error); + }, + }); + } catch (error) { + reportError('Could not delete pickup event', error); + setLoading(false); + } + }; + + const defaultFormText = loading ? 'Loading events from API...' : 'Select an Event'; + + return ( +
+
+

{mode === 'edit' ? 'Modify' : 'Create'} Pickup Event

+ + {defaultData.uuid ? ( + + View pickup event page + + + ) : null} +
+ +
+ + + + + + + + + + + + + + + + + +