diff --git a/backend/src/main.ts b/backend/src/main.ts index 3fc2745aa..1276b5f47 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -24,6 +24,7 @@ async function bootstrap() { transform: true, whitelist: true, forbidNonWhitelisted: true, + validationError: { target: false, value: false }, }), ); diff --git a/backend/src/modules/booking/booking.controller.ts b/backend/src/modules/booking/booking.controller.ts index d20726395..d92917f4d 100644 --- a/backend/src/modules/booking/booking.controller.ts +++ b/backend/src/modules/booking/booking.controller.ts @@ -208,14 +208,14 @@ export class BookingController { /** * Create a new booking. - * Accessible by users and requesters within their organization. + * Accessible by users, requesters, storage managers, and tenant admins within their organization. * @param dto - Booking data * @param req - Authenticated request object * @returns Created booking */ //TODO: attach activeRole to the booking @Post() - @Roles(["user", "requester"], { + @Roles(["user", "requester", "storage_manager", "tenant_admin"], { match: "any", sameOrg: true, }) diff --git a/backend/src/modules/booking/booking.service.ts b/backend/src/modules/booking/booking.service.ts index 49a992684..aa43996f2 100644 --- a/backend/src/modules/booking/booking.service.ts +++ b/backend/src/modules/booking/booking.service.ts @@ -354,7 +354,35 @@ export class BookingService { throw new BadRequestException("No userId found: user_id is required"); } + // 3.0. Check if user has completed required profile information + const { data: userProfile, error: profileError } = await supabase + .from("user_profiles") + .select("full_name, phone") + .eq("id", userId) + .single(); + + if (profileError || !userProfile) { + throw new BadRequestException("User profile not found"); + } + + // Require full name for booking (phone is optional but recommended) + if (!userProfile.full_name || userProfile.full_name.trim() === "") { + // Use a specific error code that frontend can catch to show the modal + const error = new BadRequestException({ + message: "Profile incomplete: Full name is required to create bookings", + errorCode: "PROFILE_INCOMPLETE", + missingFields: ["full_name"], + hasPhone: !!(userProfile.phone && userProfile.phone.trim()), + }); + throw error; + } + + // Check for phone number and prepare warning message let warningMessage: string | null = null; + if (!userProfile.phone || userProfile.phone.trim() === "") { + warningMessage = + "We recommend adding a phone number to your profile for easier communication about your bookings."; + } for (const item of dto.items) { const { item_id, quantity, start_date, end_date } = item; @@ -369,8 +397,15 @@ export class BookingService { // Warn for short notice (< 24h). dayDiffFromToday returns 0 for same-day future times. if (diffDays < 1) { - warningMessage = + const shortNoticeWarning = "Heads up: bookings made less than 24 hours in advance might not be approved in time."; + + // Combine warnings if both exist + if (warningMessage) { + warningMessage = `${warningMessage} ${shortNoticeWarning}`; + } else { + warningMessage = shortNoticeWarning; + } } // 3.1. Check availability for requested date range diff --git a/common/supabase.types.ts b/common/supabase.types.ts index 9fec1336d..5b6cc8ece 100644 --- a/common/supabase.types.ts +++ b/common/supabase.types.ts @@ -17,10 +17,10 @@ export type Database = { Functions: { graphql: { Args: { + extensions?: Json operationName?: string query?: string variables?: Json - extensions?: Json } Returns: Json } @@ -1364,14 +1364,14 @@ export type Database = { } create_notification: { Args: { - p_user_id: string - p_type: Database["public"]["Enums"]["notification_type"] - p_title: string - p_message?: string p_channel?: Database["public"]["Enums"]["notification_channel"] - p_severity?: Database["public"]["Enums"]["notification_severity"] - p_metadata?: Json p_idempotency_key?: string + p_message?: string + p_metadata?: Json + p_severity?: Database["public"]["Enums"]["notification_severity"] + p_title: string + p_type: Database["public"]["Enums"]["notification_type"] + p_user_id: string } Returns: undefined } @@ -1380,11 +1380,11 @@ export type Database = { Returns: string } get_all_full_bookings: { - Args: { in_offset: number; in_limit: number } + Args: { in_limit: number; in_offset: number } Returns: Json } get_all_full_orders: { - Args: { in_offset?: number; in_limit?: number } + Args: { in_limit?: number; in_offset?: number } Returns: Json } get_full_booking: { @@ -1396,26 +1396,26 @@ export type Database = { Returns: Json } get_full_user_booking: { - Args: { in_user_id: string; in_offset: number; in_limit: number } + Args: { in_limit: number; in_offset: number; in_user_id: string } Returns: Json } get_full_user_order: { - Args: { in_user_id: string; in_offset?: number; in_limit?: number } + Args: { in_limit?: number; in_offset?: number; in_user_id: string } Returns: Json } get_latest_ban_record: { Args: { check_user_id: string } Returns: { - id: string - ban_type: string action: string ban_reason: string - is_permanent: boolean - banned_by: string + ban_type: string banned_at: string - unbanned_at: string + banned_by: string + id: string + is_permanent: boolean organization_id: string role_assignment_id: string + unbanned_at: string }[] } get_request_user_id: { @@ -1432,19 +1432,19 @@ export type Database = { get_user_roles: { Args: { user_uuid: string } Returns: { + created_at: string id: string - user_id: string - organization_id: string - role_id: string is_active: boolean - created_at: string - role_name: string + organization_id: string organization_name: string organization_slug: string + role_id: string + role_name: string + user_id: string }[] } is_admin: { - Args: { p_user_id: string; p_org_id?: string } + Args: { p_org_id?: string; p_user_id: string } Returns: boolean } is_user_banned_for_app: { @@ -1452,26 +1452,26 @@ export type Database = { Returns: boolean } is_user_banned_for_org: { - Args: { check_user_id: string; check_org_id: string } + Args: { check_org_id: string; check_user_id: string } Returns: boolean } is_user_banned_for_role: { Args: { - check_user_id: string check_org_id: string check_role_id: string + check_user_id: string } Returns: boolean } notify: { Args: { - p_user_id: string - p_type_txt: string - p_title?: string - p_message?: string p_channel?: Database["public"]["Enums"]["notification_channel"] - p_severity?: Database["public"]["Enums"]["notification_severity"] + p_message?: string p_metadata?: Json + p_severity?: Database["public"]["Enums"]["notification_severity"] + p_title?: string + p_type_txt: string + p_user_id: string } Returns: undefined } diff --git a/frontend/src/api/services/users.ts b/frontend/src/api/services/users.ts index 6bb30415d..e47a06159 100644 --- a/frontend/src/api/services/users.ts +++ b/frontend/src/api/services/users.ts @@ -1,6 +1,6 @@ import { CreateUserDto, UserProfile } from "@common/user.types"; import { api } from "../axios"; -import { Address } from "@/types/address"; +import { Address, CreateAddressInput } from "@/types/address"; import { store } from "@/store/store"; import { ApiResponse } from "@/types/api"; import { OrderedUsersParams } from "@/types/user"; @@ -88,23 +88,23 @@ export const usersApi = { /** * Add a new address for a user * @param id - User ID to add the address to - * @param address - Address data to add + * @param address - Address data to add (without user_id as it's in the URL) * @returns Promise with the newly added address */ - addAddress: (id: string, address: Address): Promise
=> + addAddress: (id: string, address: CreateAddressInput): Promise
=> api.post(`/users/${id}/addresses`, address), /** * Update an existing address for a user * @param id - User ID to update the address for * @param addressId - Address ID to update - * @param address - Updated address data + * @param address - Updated address data (without user_id as it's in the URL) * @returns Promise with the updated address */ updateAddress: ( id: string, addressId: string, - address: Address, + address: CreateAddressInput, ): Promise
=> api.put(`/users/${id}/addresses/${addressId}`, address), diff --git a/frontend/src/components/Profile/ProfileCompletionModal.tsx b/frontend/src/components/Profile/ProfileCompletionModal.tsx new file mode 100644 index 000000000..5cb8d070a --- /dev/null +++ b/frontend/src/components/Profile/ProfileCompletionModal.tsx @@ -0,0 +1,395 @@ +// Only the editable address fields in this modal +type AddressFormKeys = "street_address" | "city" | "postal_code" | "country"; +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Loader2, User, Phone, MapPin } from "lucide-react"; +import { toast } from "sonner"; +import type { AddressForm } from "@/types/address"; +import { useLanguage } from "@/context/LanguageContext"; +import { t } from "@/translations"; + +export interface ProfileCompletionData { + full_name: string; + phone?: string; +} + +export interface CompleteProfileData { + profile: ProfileCompletionData; + address?: Partial; +} + +interface ProfileCompletionModalProps { + isOpen: boolean; + onClose: () => void; + onComplete: (data: CompleteProfileData) => Promise; + missingFields?: string[]; + hasPhone?: boolean; +} + +export const ProfileCompletionModal: React.FC = ({ + isOpen, + onClose, + onComplete, + missingFields = ["full_name"], + hasPhone = false, +}) => { + const { lang } = useLanguage(); + const [loading, setLoading] = useState(false); + const [profileData, setProfileData] = useState({ + full_name: "", + phone: "", + }); + const [addressData, setAddressData] = useState>({ + street_address: "", + city: "", + postal_code: "", + country: "", + }); + const [errors, setErrors] = useState<{ + full_name?: string; + phone?: string; + street_address?: string; + city?: string; + postal_code?: string; + country?: string; + }>({}); + + const needsFullName = missingFields.includes("full_name"); + const recommendPhone = !hasPhone; + + const validateForm = (): boolean => { + const newErrors: { + full_name?: string; + phone?: string; + street_address?: string; + city?: string; + postal_code?: string; + country?: string; + } = {}; + + if (needsFullName && !profileData.full_name.trim()) { + newErrors.full_name = + t.cart.profileCompletion.fields.fullName.required[lang]; + } + + // Basic address validation - only if any address field is filled + const hasAnyAddressData = + (addressData.street_address ?? "").trim() || + (addressData.city ?? "").trim() || + (addressData.postal_code ?? "").trim() || + (addressData.country ?? "").trim(); + + if (hasAnyAddressData) { + if (!(addressData.street_address ?? "").trim()) { + newErrors.street_address = + t.cart.profileCompletion.fields.address.streetAddress.required[lang]; + } + if (!(addressData.city ?? "").trim()) { + newErrors.city = + t.cart.profileCompletion.fields.address.city.required[lang]; + } + if (!(addressData.postal_code ?? "").trim()) { + newErrors.postal_code = + t.cart.profileCompletion.fields.address.postalCode.required[lang]; + } + if (!(addressData.country ?? "").trim()) { + newErrors.country = + t.cart.profileCompletion.fields.address.country.required[lang]; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setLoading(true); + try { + const hasAddressData = + (addressData.street_address ?? "").trim() || + (addressData.city ?? "").trim() || + (addressData.postal_code ?? "").trim() || + (addressData.country ?? "").trim(); + + const completeData: CompleteProfileData = { + profile: profileData, + ...(hasAddressData && { address: addressData }), + }; + + const success = await onComplete(completeData); + if (success) { + onClose(); + } else { + toast.error(t.cart.profileCompletion.errors.updateFailed[lang]); + } + } catch (error) { + console.error("Error updating profile:", error); + toast.error(t.cart.profileCompletion.errors.updateFailed[lang]); + } finally { + setLoading(false); + } + }; + + const handleProfileChange = ( + field: keyof ProfileCompletionData, + value: string, + ) => { + setProfileData((prev) => ({ ...prev, [field]: value })); + // Clear error when user starts typing + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }; + + const handleAddressChange = (field: AddressFormKeys, value: string) => { + setAddressData((prev) => ({ ...prev, [field]: value })); + // Clear error when user starts typing (narrowed to AddressFormKeys) + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }; + + const handleCancel = () => { + setProfileData({ full_name: "", phone: "" }); + setAddressData({ + street_address: "", + city: "", + postal_code: "", + country: "", + }); + setErrors({}); + onClose(); + }; + + return ( + {}}> + e.preventDefault()} + > + + + + {t.cart.profileCompletion.title[lang]} + + + {t.cart.profileCompletion.description[lang]} + + + +
+ {needsFullName && ( +
+ + + handleProfileChange("full_name", e.target.value) + } + placeholder={ + t.cart.profileCompletion.fields.fullName.placeholder[lang] + } + disabled={loading} + className={errors.full_name ? "border-red-500" : ""} + /> + {errors.full_name && ( +

{errors.full_name}

+ )} +
+ )} + +
+ + handleProfileChange("phone", e.target.value)} + placeholder={ + t.cart.profileCompletion.fields.phone.placeholder[lang] + } + disabled={loading} + /> + {recommendPhone && ( +

+ {t.cart.profileCompletion.fields.phone.description[lang]} +

+ )} +
+ +
+ +

+ {t.cart.profileCompletion.fields.address.description[lang]} +

+ +
+
+ + + handleAddressChange("street_address", e.target.value) + } + placeholder={ + t.cart.profileCompletion.fields.address.streetAddress + .placeholder[lang] + } + disabled={loading} + className={errors.street_address ? "border-red-500" : ""} + /> + {errors.street_address && ( +

+ {errors.street_address} +

+ )} +
+ +
+
+ + + handleAddressChange("city", e.target.value) + } + placeholder={ + t.cart.profileCompletion.fields.address.city.placeholder[ + lang + ] + } + disabled={loading} + className={errors.city ? "border-red-500" : ""} + /> + {errors.city && ( +

{errors.city}

+ )} +
+
+ + + handleAddressChange("postal_code", e.target.value) + } + placeholder={ + t.cart.profileCompletion.fields.address.postalCode + .placeholder[lang] + } + disabled={loading} + className={errors.postal_code ? "border-red-500" : ""} + /> + {errors.postal_code && ( +

{errors.postal_code}

+ )} +
+
+ +
+ + + handleAddressChange("country", e.target.value) + } + placeholder={ + t.cart.profileCompletion.fields.address.country.placeholder[ + lang + ] + } + disabled={loading} + className={errors.country ? "border-red-500" : ""} + /> + {errors.country && ( +

{errors.country}

+ )} +
+
+
+ + + + + +
+
+
+ ); +}; diff --git a/frontend/src/hooks/useProfileCompletion.ts b/frontend/src/hooks/useProfileCompletion.ts new file mode 100644 index 000000000..c53c72d5e --- /dev/null +++ b/frontend/src/hooks/useProfileCompletion.ts @@ -0,0 +1,207 @@ +import type { AddressForm, CreateAddressInput } from "@/types/address"; +import { useState, useEffect, useCallback } from "react"; +import axios from "axios"; +import { useAuth } from "@/hooks/useAuth"; +import { useAppSelector, useAppDispatch } from "@/store/hooks"; +import { + selectSelectedUser, + selectUserAddresses, + updateUser, + addAddress, + getUserAddresses, +} from "@/store/slices/usersSlice"; +import { CompleteProfileData } from "@/components/Profile/ProfileCompletionModal"; + +export interface ProfileCompletionStatus { + isComplete: boolean; + hasName: boolean; + hasPhone: boolean; + missingFields: string[]; + warnings: string[]; +} + +export interface ProfileCompletionData { + full_name: string; + phone?: string; +} + +/** + * Hook to check if user's profile is complete for booking + * Requires full_name, recommends phone number + */ +export function useProfileCompletion(): { + status: ProfileCompletionStatus | null; + loading: boolean; + checkProfile: () => void; + updateProfile: (data: CompleteProfileData) => Promise; +} { + const { user } = useAuth(); + const dispatch = useAppDispatch(); + const userProfile = useAppSelector(selectSelectedUser); + const existingAddresses = useAppSelector(selectUserAddresses); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + + const checkProfile = useCallback(() => { + if (!user) { + setStatus(null); + return; + } + + setLoading(true); + try { + // Use the full user profile from Redux if available, otherwise use basic user data + const profile = userProfile || user; + + // Type-safe access to profile fields + const profileWithName = profile as { full_name?: string }; + const profileWithPhone = profile as { phone?: string }; + + const hasName = !!( + profileWithName.full_name && profileWithName.full_name.trim() + ); + + const hasPhone = !!( + profileWithPhone.phone && profileWithPhone.phone.trim() + ); + + const missingFields: string[] = []; + const warnings: string[] = []; + + if (!hasName) { + missingFields.push("full_name"); + } + + if (!hasPhone) { + warnings.push( + "Phone number is recommended for easier communication about your bookings", + ); + } + + const isComplete = hasName; // Only name is required for booking + + setStatus({ + isComplete, + hasName, + hasPhone, + missingFields, + warnings, + }); + } catch (error) { + console.error("Error checking profile completion:", error); + setStatus({ + isComplete: false, + hasName: false, + hasPhone: false, + missingFields: ["full_name"], + warnings: [], + }); + } finally { + setLoading(false); + } + }, [user, userProfile]); + + const updateProfile = useCallback( + async (data: CompleteProfileData): Promise => { + if (!user?.id) { + return false; + } + + try { + // 1) Update user profile (full_name/phone) + const updateData = { + id: user.id, + full_name: data.profile.full_name.trim(), + ...(data.profile.phone && + data.profile.phone.trim() && { phone: data.profile.phone.trim() }), + }; + + await dispatch(updateUser({ id: user.id, data: updateData })).unwrap(); + + // 2) Optionally add address — align with MyProfile payload exactly + if (data.address) { + // Ensure we have the latest addresses to decide default flag + try { + await dispatch(getUserAddresses(user.id)).unwrap(); + } catch (e) { + if (process.env.NODE_ENV !== "production") { + console.error("Error fetching user addresses:", e); + } + } + + const hasAnyAddress = (existingAddresses?.length ?? 0) > 0; + const hasDefault = + existingAddresses?.some((a) => a.is_default) ?? false; + + // Normalize incoming fields to snake_case & trimmed + const street = data.address.street_address?.trim(); + const city = data.address.city?.trim(); + const postal = data.address.postal_code?.trim(); + const country = data.address.country?.trim(); + const addrType = (data.address.address_type?.trim() || "both") as + | "both" + | "billing" + | "shipping"; + + if (!street || !city) { + console.warn("Address skipped: street and city are required"); + } else { + // 1) Build a frontend AddressForm value (with user_id) + const addressForm: AddressForm = { + user_id: user.id, + address_type: addrType, + street_address: street, + city, + postal_code: postal ?? "", + country: country ?? "", + is_default: !(hasAnyAddress && hasDefault), + }; + + // 2) Map to backend DTO shape (CreateAddressDto) — drop user_id + const createDto: CreateAddressInput = { + address_type: addressForm.address_type, + street_address: addressForm.street_address, + city: addressForm.city, + postal_code: addressForm.postal_code, + country: addressForm.country, + is_default: addressForm.is_default, + }; + + try { + await dispatch( + addAddress({ + id: user.id, + address: createDto, + }), + ).unwrap(); + } catch (err: unknown) { + if (axios.isAxiosError(err)) { + const msg = + err.response?.data?.message || err.message || "Bad Request"; + console.error("Address add failed:", msg, err.response?.data); + } else { + console.error("Address add failed:", err); + } + throw err; + } + } + } + + // 3) Refresh the profile status + checkProfile(); + + return true; + } catch (error) { + console.error("Error updating profile:", error); + return false; + } + }, + [user?.id, dispatch, checkProfile, existingAddresses], + ); + + useEffect(() => { + checkProfile(); + }, [checkProfile]); + + return { status, loading, checkProfile, updateProfile }; +} diff --git a/frontend/src/pages/Cart.tsx b/frontend/src/pages/Cart.tsx index f078dec65..8325a15a5 100644 --- a/frontend/src/pages/Cart.tsx +++ b/frontend/src/pages/Cart.tsx @@ -1,7 +1,12 @@ import { useLanguage } from "@/context/LanguageContext"; import { useFormattedDate } from "@/hooks/useFormattedDate"; import { useTranslation } from "@/hooks/useTranslation"; -import { selectSelectedUser } from "@/store/slices/usersSlice"; +import { useProfileCompletion } from "@/hooks/useProfileCompletion"; +import { + selectSelectedUser, + getCurrentUser, + getUserAddresses, +} from "@/store/slices/usersSlice"; import { t } from "@/translations"; import { ItemTranslation } from "@/types"; import { Calendar, ChevronLeft, LoaderCircle, Trash2 } from "lucide-react"; @@ -11,6 +16,7 @@ import { toast } from "sonner"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { toastConfirm } from "../components/ui/toastConfirm"; +import { ProfileCompletionModal } from "../components/Profile/ProfileCompletionModal"; import { useAppDispatch, useAppSelector } from "../store/hooks"; import { clearCart, @@ -37,6 +43,9 @@ const Cart: React.FC = () => { const { lang } = useLanguage(); const { formatDate } = useFormattedDate(); + // Profile completion hook + const { updateProfile } = useProfileCompletion(); + const [availabilityMap, setAvailabilityMap] = useState<{ [itemId: string]: { availableQuantity: number; @@ -44,6 +53,7 @@ const Cart: React.FC = () => { error: string | null; }; }>({}); + const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); // Get start and end dates from the timeframe Redux slice const { startDate: startDateStr, endDate: endDateStr } = useAppSelector( @@ -175,13 +185,16 @@ const Cart: React.FC = () => { end_date: endDate.toISOString(), })), }; + try { - await toast.promise(dispatch(createBooking(bookingData)).unwrap(), { - loading: t.cart.toast.creatingBooking[lang], - success: t.cart.toast.bookingCreated[lang], - error: (err) => - `${t.cart.toast.bookingError[lang]}${err || t.cart.toast.bookingError[lang]}`, - }); + // Show loading toast + const loadingToast = toast.loading(t.cart.toast.creatingBooking[lang]); + + // Attempt to create booking + await dispatch(createBooking(bookingData)).unwrap(); + + // If successful, show success toast and proceed + toast.success(t.cart.toast.bookingCreated[lang], { id: loadingToast }); // Clear cart after successful booking dispatch(clearCart()); @@ -191,6 +204,38 @@ const Cart: React.FC = () => { } catch (error: unknown) { console.error("Checkout error:", error); console.error("Booking data that failed:", bookingData); + + // Dismiss any existing toasts first + toast.dismiss(); + + // Check if it's a profile incomplete error - could be structured error object or string + let isProfileIncompleteError = false; + + if (typeof error === "object" && error !== null) { + const errorObj = error as { errorCode?: string; message?: string }; + if (errorObj.errorCode === "PROFILE_INCOMPLETE") { + isProfileIncompleteError = true; + } + } + + if (!isProfileIncompleteError) { + const errorMessage = + error instanceof Error ? error.message : String(error); + if ( + errorMessage.includes("PROFILE_INCOMPLETE") || + errorMessage.includes("Profile incomplete") || + errorMessage.includes("Full name is required") + ) { + isProfileIncompleteError = true; + } + } + + if (isProfileIncompleteError) { + // Show the profile completion modal instead of toast error + setIsProfileModalOpen(true); + return; + } + toast.error( `Checkout error: ${ error instanceof Error @@ -460,6 +505,47 @@ const Cart: React.FC = () => { {t.cart.buttons.clearCart[lang]} + + {/* Profile Completion Modal */} + setIsProfileModalOpen(false)} + onComplete={async (data) => { + const success = await updateProfile(data); + if (success) { + // Refresh current user data to ensure profile is up to date + try { + await dispatch(getCurrentUser()).unwrap(); + const id = + userProfile?.id ?? + (localStorage.getItem("userId") || undefined); + if (id) { + try { + await dispatch(getUserAddresses(id)).unwrap(); + } catch (e) { + if (process.env.NODE_ENV !== "production") { + console.error("Failed to refresh user addresses:", e); + } + } + } + } catch (error) { + console.warn("Failed to refresh user data:", error); + } + + setIsProfileModalOpen(false); + + // Show success toast with a slight delay to avoid overlap + setTimeout(() => { + toast.success(t.cart.toast.profileUpdateSuccess[lang]); + }, 100); + + return true; + } else { + toast.error(t.cart.toast.profileUpdateError[lang]); + return false; + } + }} + /> ); }; diff --git a/frontend/src/pages/MyProfile.tsx b/frontend/src/pages/MyProfile.tsx index 4e955d11a..e2b8db28c 100644 --- a/frontend/src/pages/MyProfile.tsx +++ b/frontend/src/pages/MyProfile.tsx @@ -100,7 +100,6 @@ const MyProfile = () => { country: "", is_default: false, }); - // Handle tab change with URL update const handleTabChange = (value: string) => { void navigate(`/profile?tab=${value}`); @@ -123,17 +122,27 @@ const MyProfile = () => { // Loop through addresses and update or add them for (const addr of addresses) { + // Convert AddressForm to CreateAddressInput by excluding user_id and id + const addressInput = { + address_type: addr.address_type, + street_address: addr.street_address, + city: addr.city, + postal_code: addr.postal_code, + country: addr.country, + is_default: addr.is_default, + }; + if (addr.id) { void dispatch( updateAddress({ id: selectedUser.id, addressId: addr.id, - address: addr, + address: addressInput, }), ).unwrap(); } else { void dispatch( - addAddress({ id: selectedUser.id, address: addr }), + addAddress({ id: selectedUser.id, address: addressInput }), ).unwrap(); } } @@ -177,7 +186,8 @@ const MyProfile = () => { }), ).unwrap(); } - + // keep store in sync for subsequent visits/refreshes + await dispatch(getUserAddresses(selectedUser!.id)).unwrap(); toast.success(t.myProfile.toast.addressRemoved[lang]); } catch { toast.error(t.myProfile.toast.addressRemovalError[lang]); @@ -656,10 +666,20 @@ const MyProfile = () => { return; } + // Convert AddressForm to CreateAddressInput by excluding user_id + const addressInput = { + address_type: newAddress.address_type, + street_address: newAddress.street_address, + city: newAddress.city, + postal_code: newAddress.postal_code, + country: newAddress.country, + is_default: newAddress.is_default, + }; + dispatch( addAddress({ id: selectedUser?.id || "", - address: newAddress, + address: addressInput, }), ) .unwrap() diff --git a/frontend/src/store/slices/bookingsSlice.ts b/frontend/src/store/slices/bookingsSlice.ts index 4529760fb..0cd7948bf 100644 --- a/frontend/src/store/slices/bookingsSlice.ts +++ b/frontend/src/store/slices/bookingsSlice.ts @@ -47,6 +47,22 @@ export const createBooking = createAsyncThunk< try { return await bookingsApi.createBooking(bookingData); } catch (error: unknown) { + // For profile incomplete errors, preserve the full error structure + const apiError = error as { + response?: { + data?: { + errorCode?: string; + message?: string; + missingFields?: string[]; + hasPhone?: boolean; + }; + }; + }; + if (apiError?.response?.data?.errorCode === "PROFILE_INCOMPLETE") { + return rejectWithValue(apiError.response.data); + } + + // For other errors, use the standard error message extraction return rejectWithValue( extractErrorMessage(error, "Failed to create booking"), ); diff --git a/frontend/src/store/slices/usersSlice.ts b/frontend/src/store/slices/usersSlice.ts index 914a327ab..68bf1887c 100644 --- a/frontend/src/store/slices/usersSlice.ts +++ b/frontend/src/store/slices/usersSlice.ts @@ -3,7 +3,8 @@ import { usersApi } from "../../api/services/users"; import { RootState } from "../store"; import { supabase } from "../../config/supabase"; import { extractErrorMessage } from "@/store/utils/errorHandlers"; -import { Address } from "@/types/address"; +// Address is used for return types and state, CreateAddressInput for API calls +import { CreateAddressInput } from "@/types/address"; import { UserState, OrderedUsersParams } from "@/types/user"; import { CreateUserDto, UpdateUserDto, UserProfile } from "@common/user.types"; import { ApiResponse } from "@/types/api"; @@ -162,7 +163,7 @@ export const getUserAddresses = createAsyncThunk( export const addAddress = createAsyncThunk( "users/addAddress", async ( - { id, address }: { id: string; address: Address }, + { id, address }: { id: string; address: CreateAddressInput }, { rejectWithValue }, ) => { try { @@ -183,7 +184,7 @@ export const updateAddress = createAsyncThunk( id, addressId, address, - }: { id: string; addressId: string; address: Address }, + }: { id: string; addressId: string; address: CreateAddressInput }, { rejectWithValue }, ) => { try { diff --git a/frontend/src/translations/modules/cart.ts b/frontend/src/translations/modules/cart.ts index 4b4695369..e245e1f6e 100644 --- a/frontend/src/translations/modules/cart.ts +++ b/frontend/src/translations/modules/cart.ts @@ -136,5 +136,147 @@ export const cart = { fi: "Virhe: ", en: "Error: ", }, + profileUpdateSuccess: { + fi: "Profiili päivitetty onnistuneesti! Voit nyt yrittää varausta uudelleen.", + en: "Profile updated successfully! Please try booking again.", + }, + profileUpdateError: { + fi: "Profiilin päivittäminen epäonnistui. Yritä uudelleen.", + en: "Failed to update profile. Please try again.", + }, + }, + profileCompletion: { + title: { + fi: "Täydennä profiilisi", + en: "Complete Your Profile", + }, + description: { + fi: "Anna tietosi jatkaaksesi varaustasi.", + en: "Please provide your details to continue with your booking.", + }, + fields: { + fullName: { + label: { + fi: "Koko nimi", + en: "Full Name", + }, + placeholder: { + fi: "Syötä koko nimesi", + en: "Enter your full name", + }, + required: { + fi: "Koko nimi vaaditaan", + en: "Full name is required", + }, + }, + phone: { + label: { + fi: "Puhelinnumero", + en: "Phone Number", + }, + placeholder: { + fi: "+358 12 345 6789", + en: "+358 12 345 6789", + }, + recommended: { + fi: "(suositeltu)", + en: "(recommended)", + }, + description: { + fi: "Puhelinnumeron lisääminen helpottaa varaustesi yhteydenpitoa.", + en: "Adding a phone number helps us communicate about your bookings more easily.", + }, + }, + address: { + label: { + fi: "Osoite", + en: "Address", + }, + optional: { + fi: "(valinnainen)", + en: "(optional)", + }, + description: { + fi: "Osoitteen lisääminen auttaa tuotteiden toimitus- ja noutojärjestelyissä.", + en: "Adding your address helps with item delivery and pickup coordination.", + }, + streetAddress: { + label: { + fi: "Katuosoite", + en: "Street Address", + }, + placeholder: { + fi: "Katukatu 123", + en: "123 Main Street", + }, + required: { + fi: "Katuosoite vaaditaan", + en: "Street address is required", + }, + }, + city: { + label: { + fi: "Kaupunki", + en: "City", + }, + placeholder: { + fi: "Helsinki", + en: "Helsinki", + }, + required: { + fi: "Kaupunki vaaditaan", + en: "City is required", + }, + }, + postalCode: { + label: { + fi: "Postinumero", + en: "Postal Code", + }, + placeholder: { + fi: "00100", + en: "00100", + }, + required: { + fi: "Postinumero vaaditaan", + en: "Postal code is required", + }, + }, + country: { + label: { + fi: "Maa", + en: "Country", + }, + placeholder: { + fi: "Suomi", + en: "Finland", + }, + required: { + fi: "Maa vaaditaan", + en: "Country is required", + }, + }, + }, + }, + buttons: { + cancel: { + fi: "Peruuta", + en: "Cancel", + }, + complete: { + fi: "Täydennä profiili", + en: "Complete Profile", + }, + updating: { + fi: "Päivitetään...", + en: "Updating...", + }, + }, + errors: { + updateFailed: { + fi: "Profiilin päivittäminen epäonnistui.", + en: "An error occurred while updating your profile.", + }, + }, }, }; diff --git a/frontend/src/types/address.ts b/frontend/src/types/address.ts index 96e7b0ad5..f06bf235f 100644 --- a/frontend/src/types/address.ts +++ b/frontend/src/types/address.ts @@ -1,5 +1,5 @@ export interface Address { - id?: string; + id?: string; // Not actually required when creating a new address user_id: string; address_type: "both" | "billing" | "shipping"; street_address: string; @@ -8,5 +8,14 @@ export interface Address { country: string; is_default: boolean; } +// DTO-shape for POST /users/:id/addresses (matches backend CreateAddressDto) +export type CreateAddressInput = { + address_type: "both" | "billing" | "shipping"; + street_address: string; + city: string; + postal_code: string; // validated with IsPostalCode('any') on the server + country: string; + is_default: boolean; +}; export type AddressForm = Omit & Partial>; diff --git a/supabase/config.toml b/supabase/config.toml index def55fd6a..9d52b8abf 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -262,9 +262,9 @@ max_frequency = "5s" # `twitter`, `slack`, `spotify`, `workos`, `zoom`. [auth.external.google] enabled = true -client_id = "secret(GOOGLE_CLIENT_ID)" +client_id = "env(GOOGLE_CLIENT_ID)" # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: -secret = "secret(GOOGLE_CLIENT_SECRET)" +secret = "env(GOOGLE_CLIENT_SECRET)" # Overrides the default auth redirectUrl. redirect_uri = "http://127.0.0.1:54321/auth/v1/callback" # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, @@ -351,11 +351,20 @@ s3_secret_key = "env(S3_SECRET_KEY)" # Declare the 'develop' branch as persistent so configs/seed/functions can deploy in PRs. [remotes.develop] project_id = "kpqrzaisoyxqillzpbms" +[remotes.develop.auth.external.google] +enabled = true +client_id = "env(GOOGLE_CLIENT_ID)" +secret = "env(GOOGLE_CLIENT_SECRET)" # Declare the 'main' branch for production [remotes.main] project_id = "rcbddkhvysexkvgqpcud" +[remotes.main.auth.external.google] +enabled = true +client_id = "env(GOOGLE_CLIENT_ID)" +secret = "env(GOOGLE_CLIENT_SECRET)" + # (Optional) To auto-deploy Edge Functions from this repo, declare them explicitly. # Replace with the folder name under supabase/functions/