Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const ServicesList: React.FC<ServicesListProps> = ({ className = "" }) => {
/>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 pb-10">
{servicesWithData.map(({ service, serviceData }) => (
<div key={service.id}>
<ServiceListItem service={service} serviceData={serviceData} />
Expand Down
51 changes: 20 additions & 31 deletions src/frontend/src/components/provider/add service/ServiceDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from "react";
import { TrashIcon, PlusCircleIcon } from "@heroicons/react/24/solid";
import { ServiceCategory } from "../../../services/serviceCanisterService";
import { ResponsiveSelect } from "../../ui/ResponsiveDropdown";

// Validation errors interface
interface ValidationErrors {
Expand Down Expand Up @@ -145,11 +146,6 @@ const ServiceDetails: React.FC<ServiceDetailsProps> = ({
handleChange(e);
};

const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setHideCategoryError(true);
handleChange(e);
};

// Check if selected category requires client proof images
const requiresProofKeywords = [
"repair",
Expand Down Expand Up @@ -267,35 +263,28 @@ const ServiceDetails: React.FC<ServiceDetailsProps> = ({
>
Select Category
</label>
<select
<ResponsiveSelect
name="categoryId"
id="categoryId"
value={formData.categoryId}
onChange={handleCategoryChange}
required
ref={categoryRef}
className={`block w-full rounded-xl border px-4 py-3 shadow-sm transition-all focus:ring-2 focus:ring-yellow-400 sm:text-sm ${
validationErrors.categoryId && !hideCategoryError
? "border-red-300 bg-red-50 focus:border-red-500"
: "border-gray-200 bg-gray-50 focus:border-yellow-400"
}`}
>
<option value="" disabled>
Select a category
</option>
{loadingCategories ? (
<option disabled>Loading categories...</option>
) : (
categories
.filter((cat) => !cat.parentId)
.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))
)}
{/* 'Other' custom category removed */}
</select>
onChange={(value) => {
const event = {
target: {
name: "categoryId",
value: value,
},
} as React.ChangeEvent<HTMLSelectElement>;
handleChange(event);
setHideCategoryError(true);
}}
options={categories
.filter((cat) => !cat.parentId)
.map((cat) => ({ value: cat.id, label: cat.name }))}
placeholder="Select a category"
error={!!(validationErrors.categoryId && !hideCategoryError)}
loading={loadingCategories}
required={true}
/>
{categoryRequiresProof && (
<div className="mt-2 flex items-start gap-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700">
<svg
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from "react";
import { MapPinIcon } from "@heroicons/react/24/solid";
import { ResponsiveSelect } from "../../ui/ResponsiveDropdown";
import phLocations from "../../../data/ph_locations.json"; // Adjust path as needed
import { useLocationStore } from "../../../store/locationStore";

Expand Down Expand Up @@ -68,8 +69,7 @@ const ServiceLocation: React.FC<ServiceLocationProps> = ({
const [manualCityOptions, setManualCityOptions] = useState<string[]>([]);

// Handle province dropdown change
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const province = e.target.value;
const handleProvinceChange = (province: string) => {
setManualProvince(province);
setManualCity("");
setFormData((prev: any) => ({
Expand All @@ -92,8 +92,7 @@ const ServiceLocation: React.FC<ServiceLocationProps> = ({
};

// Handle city dropdown change
const handleCityChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const city = e.target.value;
const handleCityChange = (city: string) => {
setManualCity(city);
setFormData((prev: any) => ({
...prev,
Expand Down Expand Up @@ -414,38 +413,38 @@ const ServiceLocation: React.FC<ServiceLocationProps> = ({
Province
<span className="ml-1 text-red-500">*</span>
</label>
<select
className="rounded-md border border-blue-300 px-3 py-2"
<ResponsiveSelect
name="locationProvince"
id="locationProvince"
value={manualProvince}
onChange={handleProvinceChange}
>
<option value="">Select Province</option>
{phLocations &&
Array.isArray(phLocations.provinces) &&
phLocations.provinces.map((prov: any) => (
<option key={prov.name} value={prov.name}>
{prov.name}
</option>
))}
</select>
options={
phLocations && Array.isArray(phLocations.provinces)
? phLocations.provinces.map((prov: any) => ({
value: prov.name,
label: prov.name,
}))
: []
}
placeholder="Select Province"
/>

<label className="text-sm font-medium text-blue-700">
City / Municipality
<span className="ml-1 text-red-500">*</span>
</label>
<select
className="rounded-md border border-blue-300 px-3 py-2"
<ResponsiveSelect
name="locationMunicipalityCity"
id="locationMunicipalityCity"
value={manualCity}
onChange={handleCityChange}
options={manualCityOptions.map((city) => ({
value: city,
label: city,
}))}
placeholder="Select City / Municipality"
disabled={!manualProvince}
>
<option value="">Select City / Municipality</option>
{manualCityOptions.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
/>
</div>
</div>
)}
Expand Down
193 changes: 193 additions & 0 deletions src/frontend/src/components/ui/ResponsiveDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { useState, useRef, useEffect, Fragment, ReactNode } from "react";
import { ChevronDownIcon } from "@heroicons/react/24/solid";

interface ResponsiveDropdownProps {
triggerRef: React.RefObject<HTMLButtonElement | null>;
isOpen: boolean;
onClose: () => void;
children: ReactNode;
position?: "left" | "right";
width?: string;
}

export function ResponsiveDropdown({
triggerRef,
isOpen,
onClose,
children,
position = "left",
width = "w-full",
}: ResponsiveDropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
const [flipUp, setFlipUp] = useState(false);

useEffect(() => {
if (isOpen && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const threshold = 250;

if (spaceBelow < threshold && spaceAbove > spaceBelow) {
setFlipUp(true);
} else {
setFlipUp(false);
}
}
}, [isOpen, triggerRef]);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(event.target as Node)
) {
onClose();
}
};

if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}

return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen, onClose, triggerRef]);

return (
<Fragment>
{isOpen && (
<div
ref={dropdownRef}
className={`absolute z-50 ${width} ${
flipUp
? "bottom-full mb-2"
: "mt-2"
} ${
position === "right" ? "right-0" : "left-0"
} rounded-xl border border-gray-100 bg-white py-1 shadow-lg focus:outline-none`}
>
{children}
</div>
)}
</Fragment>
);
}

interface SelectOption {
value: string;
label: string;
}

interface ResponsiveSelectProps {
name: string;
id: string;
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
error?: boolean;
disabled?: boolean;
loading?: boolean;
required?: boolean;
}

export function ResponsiveSelect({
name,
id,
value,
onChange,
options,
placeholder = "Select an option",
error = false,
disabled = false,
loading = false,
required = false,
}: ResponsiveSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);

const selectedOption = options.find((opt) => opt.value === value);

const handleSelect = (optionValue: string) => {
onChange(optionValue);
setIsOpen(false);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
}
};

return (
<div className="relative" onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
type="button"
id={id}
name={name}
disabled={disabled || loading}
onClick={() => !disabled && !loading && setIsOpen(!isOpen)}
className={`flex w-full items-center justify-between rounded-xl border px-4 py-3 text-left text-sm shadow-sm transition-all focus:ring-2 focus:ring-yellow-400 ${
error
? "border-red-300 bg-red-50 text-red-700 focus:border-red-500"
: "border-gray-200 bg-gray-50 text-gray-900 focus:border-yellow-400"
} ${disabled || loading ? "cursor-not-allowed opacity-60" : ""}`}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
{loading ? (
<span className="text-gray-500">Loading...</span>
) : selectedOption ? (
<span>{selectedOption.label}</span>
) : (
<span className="text-gray-500">{placeholder}</span>
)}
<ChevronDownIcon
className={`ml-2 h-5 w-5 text-gray-400 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>

<ResponsiveDropdown
triggerRef={triggerRef}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
width="w-full"
position="left"
>
{options.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500">
No options available
</div>
) : (
options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option.value)}
className={`flex w-full items-center px-4 py-3 text-left text-sm transition-colors hover:bg-gray-50 ${
value === option.value
? "bg-yellow-50 text-yellow-700 font-medium"
: "text-gray-700"
}`}
role="option"
aria-selected={value === option.value}
>
{option.label}
</button>
))
)}
</ResponsiveDropdown>

{required && (
<input type="hidden" name={name} value={value} required />
)}
</div>
);
}
4 changes: 2 additions & 2 deletions src/frontend/src/pages/client/book/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1585,7 +1585,7 @@ const BookingPage: React.FC = () => {
</div>
</header>

<div className="flex-grow pb-28 pt-16 md:pt-0">
<div className="flex-grow pb-10 pt-16 md:pt-0">
<div className="mx-auto max-w-5xl px-2 py-8 md:px-8">
<div className="md:flex md:gap-x-8">
{/* Left Column Skeleton */}
Expand Down Expand Up @@ -1787,7 +1787,7 @@ const BookingPage: React.FC = () => {
</div>
</header>

<div className="flex-grow pb-28 pt-16 md:pt-0" ref={pageContainerRef}>
<div className="flex-grow pb-10 pt-16 md:pt-0" ref={pageContainerRef}>
<div className="mx-auto max-w-5xl px-2 py-8 md:px-8">
<div className="md:flex md:gap-x-8">
{/* Left column (Desktop): Packages → Schedule → Location */}
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/pages/client/booking/confirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const BookingConfirmationPage: React.FC = () => {
const location = useLocation();
const bookingDetails: BookingDetails | null = location.state?.details || null;
useEffect(() => {
window.scrollTo(0, 0);
document.title = "Booking Confirmed - SRV Client";
}, []);
useNoBackNavigation("/client/booking");
Expand Down
Loading
Loading