diff --git a/app/add-details/page.tsx b/app/add-details/page.tsx index 2183a0a..4e306d8 100644 --- a/app/add-details/page.tsx +++ b/app/add-details/page.tsx @@ -2,65 +2,28 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { UUID } from 'crypto'; import { insertUserPlants } from '@/api/supabase/queries/userPlants'; import PlantDetails from '@/components/PlantDetails'; -import { Plant, UserPlant } from '@/types/schema'; - -const plants: Plant[] = [ - { - id: 'cfed129c-1cdf-4089-89d2-83ae2fb2f83d', - plant_name: 'cabbage', - us_state: 'string', - harvest_season: 'SPRING', - water_frequency: 'string', - weeding_frequency: 'string', - indoors_start: 'string', - indoors_end: 'string', - outdoors_start: 'string', - outdoors_end: 'string', - transplant_start: 'string', - transplant_end: 'string', - harvest_start: 'string', - harvest_end: 'string', - beginner_friendly: true, - plant_tips: 'string', - img: 'string', - difficulty_level: 'HARD', - sunlight_min_hours: 1, - sunlight_max_hours: 1, - }, - { - id: '8f25fca8-6e86-486b-9a2b-79f68efa3658', - plant_name: 'tomato', - us_state: 'string', - harvest_season: 'SPRING', - water_frequency: 'string', - weeding_frequency: 'string', - indoors_start: 'string', - indoors_end: 'string', - outdoors_start: 'string', - outdoors_end: 'string', - transplant_start: 'string', - transplant_end: 'string', - harvest_start: 'string', - harvest_end: 'string', - beginner_friendly: true, - plant_tips: 'string', - img: 'string', - difficulty_level: 'HARD', - sunlight_min_hours: 1, - sunlight_max_hours: 1, - }, -]; -const user_id: UUID = '0802d796-ace8-480d-851b-d16293c74a21'; +import COLORS from '@/styles/colors'; +import { Flex } from '@/styles/containers'; +import { H1, P1 } from '@/styles/text'; +import { UserPlant } from '@/types/schema'; +import { useAuth } from '@/utils/AuthProvider'; +import { useProfile } from '@/utils/ProfileProvider'; +import { ButtonDiv, FooterButton, MoveButton } from './styles'; export default function Home() { + const { profileData, profileReady, plantsToAdd } = useProfile(); + const { userId } = useAuth(); + const router = useRouter(); + + if (profileReady && !profileData) { + router.push('/view-plants'); + } const [currentIndex, setCurrentIndex] = useState(1); const [details, setDetails] = useState[]>( - plants.map(plant => ({ plant_id: plant.id, user_id: user_id })), + plantsToAdd.map(plant => ({ plant_id: plant.id, user_id: userId! })), ); - const router = useRouter(); const getDefaultDate = () => new Date().toISOString().substring(0, 10); @@ -71,7 +34,7 @@ export default function Home() { // Set curr date in details to default date if not on submission page if ( (!currentDetail || !currentDetail.date_added) && - currentIndex <= plants.length + currentIndex <= plantsToAdd.length ) { updateInput('date_added', getDefaultDate()); } @@ -79,20 +42,16 @@ export default function Home() { if ( steps !== 0 && currentIndex + steps > 0 && - currentIndex + steps <= plants.length + 1 + currentIndex + steps <= plantsToAdd.length + 1 ) { setCurrentIndex(prevIndex => prevIndex + steps); } } - function disableNext() { - // disable next if planting type is "SELECT" or undefined - return !( - details[currentIndex - 1].planting_type - // requires refactor of details to ensure that planting_type is PlantingTypeEnum - // && details[currentIndex - 1].planting_type !== 'SELECT' - ); - } + // disable next if planting type not selected (undefined) + const disableNext = + currentIndex <= plantsToAdd.length && + !details[currentIndex - 1].planting_type; function updateInput(field: string, value: string) { const updatedDetails = [...details]; @@ -102,38 +61,71 @@ export default function Home() { }; setDetails(updatedDetails); } - - async function updateDB() { - await insertUserPlants(user_id, details); - router.push('/view-plants'); - } + const handleSubmit = async () => { + // TODO: elegantly handle not logged in case (e.g. when someonee clicks "Back") + // instead of doing userId! + try { + await insertUserPlants(userId!, details); + router.push('/view-plants'); + } catch (error) { + console.error('Error inserting user plants:', error); + } + }; return ( -
- {currentIndex !== plants.length + 1 && ( -
- updateInput('date_added', date)} - onPlantingTypeChange={type => updateInput('planting_type', type)} - /> - -

- {currentIndex} / {plants.length} -

- -
+ <> + {currentIndex !== plantsToAdd.length + 1 && ( + + + +

Add Plant Details

+ + {currentIndex} / {plantsToAdd.length} + +
+ updateInput('date_added', date)} + onPlantingTypeChange={type => updateInput('planting_type', type)} + /> +
+ + + {currentIndex > 1 && ( + move(-1)} + $secondaryColor={COLORS.shrub} + > + Back + + )} + + move(1)} + $primaryColor={disableNext ? COLORS.midgray : COLORS.shrub} + $secondaryColor="white" + > + Next + + + +
)} - {currentIndex === plants.length + 1 && ( + {currentIndex === plantsToAdd.length + 1 && (
- - + +
)} -
+ ); } diff --git a/app/add-details/styles.ts b/app/add-details/styles.ts new file mode 100644 index 0000000..328a451 --- /dev/null +++ b/app/add-details/styles.ts @@ -0,0 +1,34 @@ +import styled from 'styled-components'; +import { SmallRoundedButton } from '@/components/Button'; + +export const MoveButton = styled(SmallRoundedButton)` + border: 1px solid; + font-family: inherit; + margin-bottom: 10px; + width: 170px; + height: 50px; + font-size: 15px; + font-style: normal; + font-weight: 400; + line-height: normal; +`; +export const FooterButton = styled.div` + display: flex; + flex-direction: row; + justify-content: end; + width: 100%; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + padding: 24px; +`; + +export const ButtonDiv = styled.div` + display: flex; + width: 100%; + justify-content: space-between; + &:has(:only-child) { + justify-content: flex-end; + } +`; diff --git a/app/plant-page/all-plants/[plantId]/page.tsx b/app/plant-page/all-plants/[plantId]/page.tsx index da0c4c8..d30e163 100644 --- a/app/plant-page/all-plants/[plantId]/page.tsx +++ b/app/plant-page/all-plants/[plantId]/page.tsx @@ -11,6 +11,7 @@ import PlantCareDescription from '@/components/PlantCareDescription'; import { Flex } from '@/styles/containers'; import { H4 } from '@/styles/text'; import { Plant } from '@/types/schema'; +import { useProfile } from '@/utils/ProfileProvider'; import { BackButton, ButtonWrapper, @@ -29,6 +30,8 @@ export default function GeneralPlantPage() { const params = useParams(); const plantId: UUID = params.plantId as UUID; const [currentPlant, setCurrentPlant] = useState(); + const { profileReady, profileData, setPlantsToAdd } = useProfile(); + useEffect(() => { const getPlant = async () => { const plant = await getPlantById(plantId); @@ -36,6 +39,14 @@ export default function GeneralPlantPage() { }; getPlant(); }, [plantId]); + + const handleAdd = () => { + // assume user is onboarded + if (!currentPlant) return; + setPlantsToAdd([currentPlant]); + router.push('/add-details'); + }; + return currentPlant ? ( <> @@ -58,7 +69,10 @@ export default function GeneralPlantPage() { difficultyLevel={currentPlant.difficulty_level} /> - Add + + {/*Add button only appears if user is logged in and onboarded*/} + {profileReady && profileData && ( + Add + + )} [] = [ export default function Page() { const router = useRouter(); - const { hasPlot } = useProfile(); + const { hasPlot, profileData, profileReady, setPlantsToAdd } = useProfile(); + const { userId, loading: authLoading } = useAuth(); + const [viewingOption, setViewingOption] = useState<'myPlants' | 'all'>( hasPlot ? 'myPlants' : 'all', ); @@ -78,44 +80,47 @@ export default function Page() { DropdownOption[] >([]); const [searchTerm, setSearchTerm] = useState(''); - const user_id: UUID = '0802d796-ace8-480d-851b-d16293c74a21'; const [selectedPlants, setSelectedPlants] = useState([]); const [ownedPlants, setOwnedPlants] = useState([]); - // TODO: replace this with state from ProfileContext - const userState = 'TENNESSEE'; + const userState = profileData?.us_state ?? null; + + const profileAndAuthReady = profileReady && !authLoading; // Fetch All Plants useEffect(() => { - (async () => { - const plantList = await getAllPlants(); - // Filter by user's state, since they can only access when onboarded - // TODO: add userState to dependency array? - // Sort alphabetically first - const result = plantList - .filter(plant => plant.us_state === userState) - .sort((a, b) => a.plant_name.localeCompare(b.plant_name)); - setPlants(result); - })(); - }, []); + // Only fetch plants when profile is ready and we have a state + if (profileReady && userState) { + (async () => { + const plantList = await getAllPlants(); + const result = plantList + .filter(plant => plant.us_state === userState) + .sort((a, b) => a.plant_name.localeCompare(b.plant_name)); + setPlants(result); + })(); + } + }, [profileReady, userState]); // Fetch User Plants for My Garden tab useEffect(() => { - const fetchUserPlants = async () => { - const fetchedUserPlants = await getCurrentUserPlantsByUserId(user_id); + // Only fetch user plants if we have a valid userId + if (!authLoading && userId) { + const fetchUserPlants = async () => { + const fetchedUserPlants = await getCurrentUserPlantsByUserId(userId); - const ownedPlants: OwnedPlant[] = await Promise.all( - fetchedUserPlants.map(async userPlant => { - const plant = await getMatchingPlantForUserPlant(userPlant); - return { - userPlantId: userPlant.id, - plant, - }; - }), - ); - setOwnedPlants(ownedPlants); - }; - fetchUserPlants(); - }, []); + const ownedPlants: OwnedPlant[] = await Promise.all( + fetchedUserPlants.map(async userPlant => { + const plant = await getMatchingPlantForUserPlant(userPlant); + return { + userPlantId: userPlant.id, + plant, + }; + }), + ); + setOwnedPlants(ownedPlants); + }; + fetchUserPlants(); + } + }, [userId, authLoading]); const clearFilters = () => { setSelectedGrowingSeason([]); @@ -171,8 +176,9 @@ export default function Page() { } } function handleAddPlants() { - //TODO: route to add details with proper information - router.push('/add-details'); // use CONFIG later + setPlantsToAdd(selectedPlants); + + router.push('/add-details'); } function handleCancelAddMode() { @@ -180,6 +186,137 @@ export default function Page() { setInAddMode(false); } + function MainBody() { + // assume auth and profile are both ready + // Not logged in + if (!userId) { + return ( + + Login to view all plants + + + ); + } + + // Not onboarded + if (!profileData) { + return ( + + Complete your profile view all plants + + + ); + } + + // Onboarded and Logged in: Normal Screen + return ( + <> + + + setViewingOption('myPlants')} + > + My Plants + + setViewingOption('all')} + > + All + + + {/* Select/Cancel toggles Add Mode; appears in All plants only*/} + {viewingOption === 'all' && + (inAddMode ? ( + + Cancel + + ) : ( + setInAddMode(true)} + > + Select + + ))} + + + {viewingOption === 'myPlants' ? ( + + ) : ( + + )} + + ); + } + + function MyPlantsDisplay() { + return ( +
+ {ownedPlants.length === 0 ? ( + <>Add Plants To Your Garden + ) : filteredUserPlantList.length === 0 ? ( +

No plants match your current filters.

+ ) : ( + + {filteredUserPlantList.map(ownedPlant => ( + handleUserPlantCardClick(ownedPlant)} + // aspectRatio="168 / 200" + /> + ))} + + )} +
+ ); + } + + function AllPlantsDisplay() { + return ( + <> + {filteredPlantList.length === 0 ? ( +
+

No plants match your current filters.

+
+ ) : ( + + {filteredPlantList.map(plant => ( + handlePlantCardClick(plant)} + // aspectRatio="168 / 200" + /> + ))} + + )} + {inAddMode && ( + + {selectedPlants.length ? 'Add to My Garden' : 'Select Plants'} + + )} + + ); + } + const plantPluralityString = selectedPlants.length > 1 ? 'Plants' : 'Plant'; return ( @@ -224,90 +361,8 @@ export default function Page() { ) : null} - - - setViewingOption('myPlants')} - > - My Plants - - setViewingOption('all')} - > - All - - - {/* Select/Cancel toggles Add Mode; appears in All plants only*/} - {viewingOption === 'all' && - (inAddMode ? ( - - Cancel - - ) : ( - setInAddMode(true)} - > - Select - - ))} - - {viewingOption === 'myPlants' && ( -
- {filteredUserPlantList.length ? ( - - {filteredUserPlantList.map(ownedPlant => ( - handleUserPlantCardClick(ownedPlant)} - // aspectRatio="168 / 200" - /> - ))} - - ) : ( -
- -
- )} -
- )} - {viewingOption === 'all' && ( - <> - - {filteredPlantList.map(plant => ( - handlePlantCardClick(plant)} - // aspectRatio="168 / 200" - /> - ))} - - {inAddMode && ( - - {selectedPlants.length ? 'Add to My Garden' : 'Select Plants'} - - )} - - )} + {/* Plant Cards and Body */} + {!profileAndAuthReady ? <>Loading : }
); diff --git a/components/CustomSelect/index.tsx b/components/CustomSelect/index.tsx index b8d5b63..eb97ad3 100644 --- a/components/CustomSelect/index.tsx +++ b/components/CustomSelect/index.tsx @@ -14,6 +14,7 @@ interface CustomSelectProps { options: DropdownOption[]; onChange: (value: T) => void; label?: string; + isContainerClickable?: boolean; // New boolean prop } const CustomSelect = ({ @@ -21,6 +22,7 @@ const CustomSelect = ({ options, onChange, label, + isContainerClickable = false, // Default to false }: CustomSelectProps) => { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); @@ -47,7 +49,10 @@ const CustomSelect = ({ }, []); return ( - + setIsOpen(!isOpen) : undefined} + > {options.find(option => option.value === value)?.label || label} diff --git a/components/DateInput/index.tsx b/components/DateInput/index.tsx new file mode 100644 index 0000000..34f78f4 --- /dev/null +++ b/components/DateInput/index.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef } from 'react'; +import Icon from '../Icon'; +import { + DateInputWrapper, + DropdownIcon, + HiddenDateInput, + SelectContainer, + SelectedValue, +} from './styles'; + +interface DateInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + min?: string; + max?: string; +} + +const DateInput = ({ + value, + onChange, + placeholder = '', + min, + max, +}: DateInputProps) => { + const containerRef = useRef(null); + const hiddenInputRef = useRef(null); + + // Close the dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // Automatically show picker when dropdown icon is clicked + const handleDropdownClick = () => { + // Use requestAnimationFrame to ensure the input is rendered before showing picker + requestAnimationFrame(() => { + hiddenInputRef.current?.showPicker(); + }); + }; + + const formatDate = (dateString: string) => { + if (!dateString) return placeholder; + + try { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } catch { + return dateString; + } + }; + + return ( + + + {formatDate(value)} + + + + + { + onChange(e.target.value); + }} + min={min} + max={max} + /> + + + ); +}; + +export default DateInput; diff --git a/components/DateInput/styles.ts b/components/DateInput/styles.ts new file mode 100644 index 0000000..ee26d06 --- /dev/null +++ b/components/DateInput/styles.ts @@ -0,0 +1,74 @@ +import styled from 'styled-components'; +import COLORS from '@/styles/colors'; + +export const DropdownIcon = styled.button` + background: none; + border: none; + cursor: pointer; + color: ${COLORS.sprout}; + font-size: 1.25rem; + display: flex; + align-items: center; + svg { + fill: ${COLORS.shrub}; + width: 1.25rem; + height: 1.25rem; + } +`; + +export const OptionsContainer = styled.div` + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid ${COLORS.lightgray}; + border-radius: 5px; + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1); + margin-top: 5px; + z-index: 10; + width: 100%; +`; + +export const Option = styled.div` + padding: 0.5rem 1rem; + background: #f9f9f9; + cursor: pointer; + color: ${COLORS.shrub}; + &:hover { + background: ${COLORS.sprout}; + } +`; +export const SelectContainer = styled.div` + display: flex; + align-items: center; + position: relative; + padding: 1rem; + border: 2px solid ${COLORS.lightgray}; + border-radius: 5px; + cursor: pointer; + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1); + height: 3rem; + box-sizing: border-box; + width: 100%; +`; + +export const SelectedValue = styled.span` + flex-grow: 1; + font-size: 1rem; + color: ${COLORS.shrub}; +`; +export const DateInputWrapper = styled.div` + position: relative; + width: 100%; +`; + +export const HiddenDateInput = styled.input` + position: absolute; + top: 100%; + left: 0; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +`; diff --git a/components/PlantDetails/index.tsx b/components/PlantDetails/index.tsx index 543e37b..251f140 100644 --- a/components/PlantDetails/index.tsx +++ b/components/PlantDetails/index.tsx @@ -1,4 +1,18 @@ -import { Plant } from '@/types/schema'; +import Image from 'next/image'; +import BPLogo from '@/assets/images/bp-logo.png'; // TODO: remove this + +import COLORS from '@/styles/colors'; +import { Box, Flex } from '@/styles/containers'; +import { H3, P2 } from '@/styles/text'; +import { DropdownOption, Plant, PlantingTypeEnum } from '@/types/schema'; +import CustomSelect from '../CustomSelect'; +import DateInput from '../DateInput'; + +const plantingTypeOptions: DropdownOption[] = [ + { value: 'TRANSPLANT', label: 'Transplant' }, + { value: 'INDOORS', label: 'Indoors' }, + { value: 'OUTDOORS', label: 'Outdoors' }, +]; export default function PlantDetails({ plant, @@ -14,28 +28,61 @@ export default function PlantDetails({ onPlantingTypeChange: (type: string) => void; }) { return ( -
-

{plant.plant_name}

- - - onDateChange(e.target.value)} - /> - - - -
+ {`Plant + + +

+ {plant.plant_name} +

+ {/* onDateChange(e.target.value)} + /> */} + + + {/*TODO: Move label into DateInput component*/} + + Date Planted + + + + + + + Planting Type + + + + +
+ ); } diff --git a/utils/ProfileProvider.tsx b/utils/ProfileProvider.tsx index b13dd54..9d00cb4 100644 --- a/utils/ProfileProvider.tsx +++ b/utils/ProfileProvider.tsx @@ -13,16 +13,18 @@ import { fetchProfileById, upsertProfile, } from '@/api/supabase/queries/profiles'; -import { Profile } from '@/types/schema'; +import { Plant, Profile } from '@/types/schema'; import { useAuth } from './AuthProvider'; export interface ProfileContextType { profileData: Profile | null; profileReady: boolean; hasPlot: boolean | null; + plantsToAdd: Plant[]; setProfile: (completeProfile: Profile) => Promise; // Now expects full Profile loadProfile: () => Promise; setHasPlot: (plotValue: boolean | null) => void; + setPlantsToAdd: (plants: Plant[]) => void; } const ProfileContext = createContext(undefined); @@ -43,6 +45,7 @@ export default function ProfileProvider({ children }: ProfileProviderProps) { const [profileData, setProfileData] = useState(null); const [profileReady, setProfileReady] = useState(false); const [hasPlot, setHasPlot] = useState(null); + const [plantsToAdd, setPlantsToAdd] = useState([]); const loadProfile = useCallback(async () => { if (!userId) { @@ -87,6 +90,8 @@ export default function ProfileProvider({ children }: ProfileProviderProps) { profileData, profileReady, hasPlot, + plantsToAdd, + setPlantsToAdd, setProfile, loadProfile, setHasPlot: updateHasPlot, @@ -95,6 +100,8 @@ export default function ProfileProvider({ children }: ProfileProviderProps) { profileData, profileReady, hasPlot, + plantsToAdd, + setPlantsToAdd, setProfile, loadProfile, setHasPlot,