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 (
+
+ );
+};
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/