diff --git a/app/(main)/components/PhotoUpload.module.css b/app/(main)/components/PhotoUpload.module.css new file mode 100644 index 00000000..28c22203 --- /dev/null +++ b/app/(main)/components/PhotoUpload.module.css @@ -0,0 +1,99 @@ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + position: relative; +} + +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px dashed #cbd5e0; + background: #fff; + cursor: pointer; + transition: + border-color 0.2s ease, + background 0.2s ease; +} + +.container img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.container:hover { + border-color: #2d3748; + background: #edf2f7; +} + +.container.hasPhoto { + border: none; +} + +.container.dragging { + border-color: #2d3748; + background: #e2e8f0; + transform: scale(1.05); +} + +.circle { + width: 12.5rem; + height: 12.5rem; + border-radius: 50%; +} + +.rectangle { + width: 100%; + aspect-ratio: 1; + border-radius: 0.5rem; +} + +.placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.75rem; + text-align: center; + color: #000; + width: 100%; + height: 100%; +} + +.placeholder .placeholderImage { + width: 2rem; + height: 2rem; +} + +.removeButton { + position: absolute; + top: -1rem; + right: -1rem; + width: 2rem; + height: 2rem; + border-radius: 50%; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: 0.2s ease; + background-color: #ffefef; + border-color: #c53030; + border: 1px solid; + color: #c53030; +} + +.removeButton:hover { + background-color: #f1c9c9; +} + +.removeButtonCircle { + top: 0.8rem; + right: 0.8rem; +} diff --git a/app/(main)/components/PhotoUpload.tsx b/app/(main)/components/PhotoUpload.tsx index ef9ab79a..c71e9ab1 100644 --- a/app/(main)/components/PhotoUpload.tsx +++ b/app/(main)/components/PhotoUpload.tsx @@ -1,55 +1,133 @@ 'use client'; -import { forwardRef, useImperativeHandle, useRef } from 'react'; -import React from 'react'; - -type FileUploaderProps = { - onFileSelect: (file: File) => void; - inputRef: React.RefObject; -}; - -const FileUploader = ({ onFileSelect, inputRef }: FileUploaderProps) => { - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - onFileSelect(file); - } - }; - - return ( - - ); -}; +import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import Image from 'next/image'; +import defaultProfilePhoto from '@/public/image-placeholder.svg'; +import uploadPhotoIcon from '@/public/image-upload.svg'; +import styles from '@/app/(main)/components/PhotoUpload.module.css'; type PhotoUploadProps = { + variant?: 'circle' | 'rectangle'; + initialPhotoUrl?: string | null; onFileSelect?: (file: File) => void; + onRemove?: () => void; + previewUrl?: string | null; + isPendingDelete?: boolean; + id?: string; }; const PhotoUpload = forwardRef<{ resetFile: () => void }, PhotoUploadProps>( - ({ onFileSelect }, ref) => { + ( + { + variant = 'rectangle', + initialPhotoUrl, + onFileSelect, + onRemove, + previewUrl, + isPendingDelete, + id = 'photo-upload-input', + }, + ref, + ) => { const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); useImperativeHandle(ref, () => ({ resetFile: () => { - if (inputRef.current) { - inputRef.current.value = ''; - } + if (inputRef.current) inputRef.current.value = ''; }, })); - const handleFileUpload = (file: File) => { - onFileSelect?.(file); + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) onFileSelect?.(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => setIsDragging(false); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files?.[0]; + if (file?.type.startsWith('image/')) onFileSelect?.(file); }; - return ; + const displayImage = isPendingDelete + ? defaultProfilePhoto.src + : previewUrl || initialPhotoUrl || defaultProfilePhoto.src; + + const hasPhoto = displayImage !== defaultProfilePhoto.src; + + return ( +
+ + + {!isPendingDelete && hasPhoto && ( + + )} + + +
+ ); }, ); PhotoUpload.displayName = 'PhotoUpload'; - export default PhotoUpload; diff --git a/app/(main)/hq/[storeId]/components/EditStoreForm.module.css b/app/(main)/hq/[storeId]/components/EditStoreForm.module.css new file mode 100644 index 00000000..58a73ae3 --- /dev/null +++ b/app/(main)/hq/[storeId]/components/EditStoreForm.module.css @@ -0,0 +1,18 @@ +.layout { + display: flex; + align-items: flex-start; + gap: 3rem; + margin-bottom: 40px; + flex-wrap: wrap; +} + +.photoColumn { + width: 200px; + height: 210px; + flex-shrink: 0; +} + +.fieldsColumn { + flex: 1; + min-width: 0; +} diff --git a/app/(main)/hq/[storeId]/components/EditStoreForm.tsx b/app/(main)/hq/[storeId]/components/EditStoreForm.tsx index 3f792a2c..fd802b74 100644 --- a/app/(main)/hq/[storeId]/components/EditStoreForm.tsx +++ b/app/(main)/hq/[storeId]/components/EditStoreForm.tsx @@ -2,12 +2,11 @@ import { useForm } from 'react-hook-form'; import type { Store, StoreUpdate } from '@/app/types/store'; -import Image from 'next/image'; import { updateStore } from '@/app/actions/store'; import { useRef, useState } from 'react'; -import defaultStorePhoto from '@/public/image-placeholder.svg'; import { createClient } from '@/app/lib/supabase/browser-client'; import PhotoUpload from '@/app/(main)/components/PhotoUpload'; +import styles from '@/app/(main)/hq/[storeId]/components/EditStoreForm.module.css'; type FormValues = { name: string; @@ -130,65 +129,58 @@ export default function EditStoreForm({ store }: { store: Store }) { } }; - const displayImage = isPendingDelete - ? defaultStorePhoto.src - : previewUrl || photoUrl || defaultStorePhoto.src; - const hasDirtyTextOrImage = isDirty || !!selectedFile || isPendingDelete; return (
-
- Store photo - - {!isPendingDelete && displayImage !== defaultStorePhoto.src && ( - - )} - -
- -
- -
- - -
- -
- - -
+
+
+ +
- {hasDirtyTextOrImage && ( -
- - - +
+
+ + +
+ +
+ + +
+ + {hasDirtyTextOrImage && ( +
+ + + +
+ )}
- )} +
); diff --git a/app/(main)/hq/[storeId]/components/RemoveStoreButton.tsx b/app/(main)/hq/[storeId]/components/RemoveStoreButton.tsx index 479891da..4d7223f4 100644 --- a/app/(main)/hq/[storeId]/components/RemoveStoreButton.tsx +++ b/app/(main)/hq/[storeId]/components/RemoveStoreButton.tsx @@ -27,8 +27,8 @@ export default function RemoveStoreButton({ storeId }: RemoveStoreButtonProp) { }; return ( - ); } diff --git a/app/(main)/hq/components/AddStoreForm.tsx b/app/(main)/hq/components/AddStoreForm.tsx index 7951f866..23e76a2d 100644 --- a/app/(main)/hq/components/AddStoreForm.tsx +++ b/app/(main)/hq/components/AddStoreForm.tsx @@ -5,7 +5,6 @@ import { useState, useRef } from 'react'; import { createStore, updateStore } from '@/app/actions/store'; import { createClient } from '@/app/lib/supabase/browser-client'; import PhotoUpload from '@/app/(main)/components/PhotoUpload'; -import Image from 'next/image'; import defaultStorePhoto from '@/public/image-placeholder.svg'; type FormValues = { @@ -15,8 +14,6 @@ type FormValues = { export default function AddStoreForm() { const [isSaving, setIsSaving] = useState(false); - - // previewUrl represents the locally selected file for the UI const [previewUrl, setPreviewUrl] = useState(null); const [selectedFile, setSelectedFile] = useState(null); @@ -30,14 +27,8 @@ export default function AddStoreForm() { }, }); - const storeName = useWatch({ - control, - name: 'storeName', - }); - const storeStreetAddress = useWatch({ - control, - name: 'storeStreetAddress', - }); + const storeName = useWatch({ control, name: 'storeName' }); + const storeStreetAddress = useWatch({ control, name: 'storeStreetAddress' }); const bothFilled = storeName.trim().length > 0 && storeStreetAddress.trim().length > 0; @@ -45,14 +36,12 @@ export default function AddStoreForm() { storeName.trim().length > 0 || storeStreetAddress.trim().length > 0; const handleFileSelect = (file: File) => { - const maxSize = 200 * 1024; // 200 KB in bytes + const maxSize = 200 * 1024; if (file.size > maxSize) { alert('File is too large. Please select an image under 200 KB.'); return; } - // Create a temporary local blob URL for immediate UI feedback - const preview = URL.createObjectURL(file); - setPreviewUrl(preview); + setPreviewUrl(URL.createObjectURL(file)); setSelectedFile(file); }; @@ -75,7 +64,6 @@ export default function AddStoreForm() { const store_id = store.data.store_id; let finalPhotoUrl = defaultStorePhoto.src; - // upload photo if (selectedFile) { const { error: uploadError } = await supabase.storage .from('store_photos') @@ -92,7 +80,7 @@ export default function AddStoreForm() { await updateStore(store_id, { photo_url: finalPhotoUrl }); } } - // reset all fields + reset({ storeName: '', storeStreetAddress: '' }); setSelectedFile(null); setPreviewUrl(null); @@ -104,81 +92,64 @@ export default function AddStoreForm() { } }; - // Determine image to show the user - const displayImage = previewUrl || defaultStorePhoto.src; - return (
-
-
- Profile photo - - {/* Only show Remove if there is currently a photo and we aren't already deleting it */} - {displayImage !== defaultStorePhoto.src && ( - - )} - -
+
+
-
- - +
+
+ + +
+ +
+ + +
+ +
+ {eitherFilled && ( + + )} + {bothFilled && ( + + )} +
- -
- - -
- -
- {bothFilled && ( - - )} - - {eitherFilled && ( - - )} -
diff --git a/app/(main)/manage/[storeId]/[storeItemId]/components/DeleteStoreItemButton.tsx b/app/(main)/manage/[storeId]/[storeItemId]/components/DeleteStoreItemButton.tsx index d44a0df8..7a85bb72 100644 --- a/app/(main)/manage/[storeId]/[storeItemId]/components/DeleteStoreItemButton.tsx +++ b/app/(main)/manage/[storeId]/[storeItemId]/components/DeleteStoreItemButton.tsx @@ -30,8 +30,8 @@ export default function DeleteStoreItemButton({ } return ( - ); } diff --git a/app/(main)/profile/components/ProfileForm.module.css b/app/(main)/profile/components/ProfileForm.module.css new file mode 100644 index 00000000..13a84a71 --- /dev/null +++ b/app/(main)/profile/components/ProfileForm.module.css @@ -0,0 +1,48 @@ +.card { + border: none; + border-radius: 5px; + background: #fff; + margin-top: 8.25rem; +} + +.cardBody { + padding: 40px; + padding-top: 6.25rem; + margin-top: 50px; +} + +.userInfoText { + margin-top: 1.25rem; +} + +.avatarCircle { + display: flex; + flex-direction: column; + align-items: center; + margin-top: -12.5rem; +} + +.avatarCircle img { + width: 12.5rem; + height: 12.5rem; + border-radius: 50%; + object-fit: cover; +} + +.avatarCircleEdit { + display: flex; + flex-direction: column; + align-items: center; + margin-top: -12.5rem; +} + +.fieldGroup { + display: flex; + flex-direction: column; + margin-bottom: 16px; +} + +.profileLabel { + font-weight: 500; + margin-bottom: 5px; +} diff --git a/app/(main)/profile/components/ProfileForm.tsx b/app/(main)/profile/components/ProfileForm.tsx index a168a97e..7b986e57 100644 --- a/app/(main)/profile/components/ProfileForm.tsx +++ b/app/(main)/profile/components/ProfileForm.tsx @@ -1,12 +1,13 @@ 'use client'; import type { User, UserUpdate } from '@/app/types/user'; -import Image from 'next/image'; import { useForm } from 'react-hook-form'; import { updateUser } from '@/app/actions/user'; import { useState, useRef } from 'react'; import { createClient } from '@/app/lib/supabase/browser-client'; import PhotoUpload from '@/app/(main)/components/PhotoUpload'; +import styles from '@/app/(main)/profile/components/ProfileForm.module.css'; +import Image from 'next/image'; import defaultProfilePhoto from '@/public/image-placeholder.svg'; type ProfileFormValues = { @@ -16,15 +17,13 @@ type ProfileFormValues = { }; export default function ProfileForm({ user }: { user: User }) { + const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); - // photoUrl represents what is currently in the DB const [photoUrl, setPhotoUrl] = useState( user.profile_photo_url, ); - // previewUrl represents the locally selected file for the UI const [previewUrl, setPreviewUrl] = useState(null); const [selectedFile, setSelectedFile] = useState(null); - // Track if the user has explicitly clicked "Remove" but hasn't saved yet const [isPendingDelete, setIsPendingDelete] = useState(false); const supabase = createClient(); @@ -34,7 +33,8 @@ export default function ProfileForm({ user }: { user: User }) { register, handleSubmit, reset, - formState: { errors, isDirty }, + watch, + formState: { errors }, } = useForm({ defaultValues: { firstName: user.first_name, @@ -43,15 +43,15 @@ export default function ProfileForm({ user }: { user: User }) { }, }); + const watchedValues = watch(); + const handleFileSelect = (file: File) => { - const maxSize = 200 * 1024; // 200 KB in bytes + const maxSize = 200 * 1024; if (file.size > maxSize) { alert('File is too large. Please select an image under 200 KB.'); return; } - // Create a temporary local blob URL for immediate UI feedback - const preview = URL.createObjectURL(file); - setPreviewUrl(preview); + setPreviewUrl(URL.createObjectURL(file)); setSelectedFile(file); setIsPendingDelete(false); }; @@ -71,6 +71,7 @@ export default function ProfileForm({ user }: { user: User }) { setPhotoUrl(user.profile_photo_url); photoUploadRef.current?.resetFile(); reset(); + setIsEditing(false); }; const onSubmit = async (data: ProfileFormValues) => { @@ -82,7 +83,6 @@ export default function ProfileForm({ user }: { user: User }) { } = await supabase.auth.getUser(); if (authUser) { - // Handle deletion if flagged if (isPendingDelete) { await supabase.storage .from('profile_photos') @@ -90,7 +90,6 @@ export default function ProfileForm({ user }: { user: User }) { finalPhotoUrl = null; } - // Handle upload if a new file is selected if (selectedFile) { const { error: uploadError } = await supabase.storage .from('profile_photos') @@ -104,21 +103,18 @@ export default function ProfileForm({ user }: { user: User }) { const { data: publicData } = supabase.storage .from('profile_photos') .getPublicUrl(`${authUser.id}/profile.jpg`); - finalPhotoUrl = `${publicData.publicUrl}?t=${Date.now()}`; } } } - // Create a partial update object const changes: UserUpdate = {}; if (data.firstName !== user.first_name) changes.first_name = data.firstName; if (data.lastName !== user.last_name) changes.last_name = data.lastName; if (data.email !== user.email) changes.email = data.email; - if (selectedFile || isPendingDelete) { + if (selectedFile || isPendingDelete) changes.profile_photo_url = finalPhotoUrl; - } const result = await updateUser(user.user_id, changes); @@ -134,6 +130,7 @@ export default function ProfileForm({ user }: { user: User }) { lastName: data.lastName, email: user.email, }); + setIsEditing(false); if (data.email !== user.email) { alert('Please check your new email address to verify the change.'); } @@ -147,65 +144,124 @@ export default function ProfileForm({ user }: { user: User }) { } }; - // Determine image to show the user - const displayImage = isPendingDelete - ? defaultProfilePhoto.src - : previewUrl || photoUrl || defaultProfilePhoto.src; + return ( +
+
+
+ {isEditing ? ( +
+ +
+ ) : ( +
+ Profile photo +
+ )} +

User Information

- const hasDirtyTextOrImage = isDirty || !!selectedFile || isPendingDelete; +
+
+
+ + {isEditing ? ( + <> + + {errors.firstName?.type === 'required' && ( +

First name is required.

+ )} + + ) : ( +

+ {watchedValues.firstName} +

+ )} +
- return ( - -
- Profile photo - - {/* Only show Remove if there is currently a photo and we aren't already deleting it */} - {!isPendingDelete && displayImage !== defaultProfilePhoto.src && ( - - )} - -
- -
+
+ + {isEditing ? ( + <> + + {errors.lastName?.type === 'required' && ( +

Last name is required.

+ )} + + ) : ( +

+ {watchedValues.lastName} +

+ )} +
+
+
+ +
+ + {isEditing ? ( + <> + + {errors.email?.type === 'required' && ( +

Email is required.

+ )} + + ) : ( +

{watchedValues.email}

+ )} +
- - - {errors.firstName?.type === 'required' && ( -

First name is required.

- )} -
- - - {errors.lastName?.type === 'required' && ( -

Last name is required.

- )} -
- - - {errors.email?.type === 'required' && ( -

Email is required.

- )} -
- - {hasDirtyTextOrImage && ( - <> - - - - )} -
+ {isEditing && ( +
+ + +
+ )} + + {!isEditing && ( +
+ +
+ )} + +
+
); } diff --git a/app/(main)/profile/components/SignOutButton.tsx b/app/(main)/profile/components/SignOutButton.tsx index 16d9a341..f816ead9 100644 --- a/app/(main)/profile/components/SignOutButton.tsx +++ b/app/(main)/profile/components/SignOutButton.tsx @@ -14,5 +14,9 @@ export default function SignOutButton() { router.push('/home'); } - return ; + return ( + + ); } diff --git a/app/(main)/profile/components/UpdatePasswordForm.tsx b/app/(main)/profile/components/UpdatePasswordForm.tsx index 3fac01d0..9962c832 100644 --- a/app/(main)/profile/components/UpdatePasswordForm.tsx +++ b/app/(main)/profile/components/UpdatePasswordForm.tsx @@ -16,6 +16,7 @@ export default function UpdatePasswordForm() { reset, formState: { errors }, } = useForm({ + mode: 'onChange', defaultValues: { newPassword: '', newPasswordConfirmation: '', @@ -53,44 +54,69 @@ export default function UpdatePasswordForm() { }; return ( -
- - - {errors.newPassword && ( -

- Password must be at least 8 characters and include an uppercase - letter, a lowercase letter, a number, and a symbol. -

- )} -
- - +
+
+ +

Change Password

+
+ + +
+ {errors.newPassword && ( +

+ Password must be at least 8 characters and include an + uppercase letter, a lowercase letter, a number, and a symbol. +

+ )} +
+
+
+ + +
+ {newPasswordConfirmation.length > 0 && !passwordsMatch && ( +

Passwords do not match.

+ )} +
+
- {newPasswordConfirmation.length > 0 && !passwordsMatch && ( -

Passwords do not match.

- )} -
- {(newPassword.length > 0 || newPasswordConfirmation.length > 0) && ( - - )} - {passwordsMatch && } - + {(newPassword.length > 0 || newPasswordConfirmation.length > 0) && ( +
+ +
+ )} + {passwordsMatch && ( +
+ +
+ )} + +
+
); } diff --git a/app/(main)/profile/page.tsx b/app/(main)/profile/page.tsx index cd277d75..f1b485d5 100644 --- a/app/(main)/profile/page.tsx +++ b/app/(main)/profile/page.tsx @@ -28,11 +28,16 @@ export default async function PersonalProfilePage() { return (

Profile

-

Public Profile

+ -

Authentication

- - + +
+ +
+ +
+ +
); } diff --git a/app/(main)/request/components/AddInStockToCartForm.tsx b/app/(main)/request/components/AddInStockToCartForm.tsx index 1970f513..224ad2df 100644 --- a/app/(main)/request/components/AddInStockToCartForm.tsx +++ b/app/(main)/request/components/AddInStockToCartForm.tsx @@ -1,6 +1,5 @@ 'use client'; -import Button from 'react-bootstrap/Button'; import Form from 'react-bootstrap/Form'; import { addToCart } from '@/app/actions/ticket'; import styles from '@/app/(main)/request/[storeId]/[storeItemId]/RequestStoreItemPage.module.css'; @@ -41,9 +40,9 @@ export default function AddInStockToCartForm({ /> - + ); } diff --git a/app/globals.css b/app/globals.css index 7c466a6a..b81c6cbb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -155,16 +155,16 @@ Table { justify-content: center; align-items: center; gap: 10px; - border: none; + border: 1px solid #3182ce; border-radius: 5px; - background: #2d3748; + background: #3182ce; color: #fff; font-weight: 400; cursor: pointer; } .btn-submit:hover { - background: #4a5568; + background: #2473bc; transition: background 0.2s ease; } @@ -191,6 +191,32 @@ Table { transition: background 0.2s ease; } +.btn-remove { + display: inline-flex; + padding: 8px 40px; + justify-content: center; + align-items: center; + gap: 10px; + border: 1px solid #c53030; + border-radius: 5px; + background: #ffefef; + color: #c53030; + font-weight: 400; + cursor: pointer; +} + +.btn-remove:hover { + background: #fbe0e0; + transition: background 0.2s ease; +} + +.btn-row { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + margin-bottom: 0; +} + .form-body { display: flex; flex-direction: column; @@ -395,4 +421,5 @@ Table { .button-spacing { display: flex; gap: 12px; + flex-wrap: wrap; } diff --git a/public/image-upload.svg b/public/image-upload.svg new file mode 100644 index 00000000..b5ddd089 --- /dev/null +++ b/public/image-upload.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file