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
6 changes: 4 additions & 2 deletions app/(dashboard)/deliveries/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
export const dynamic = 'force-dynamic';

import { CreateDeliveryForm } from '@/features/deliveries/components/CreateDeliveryForm';
import { CurrencyConverter } from '@/features/deliveries/components/CurrencyConverter';

export default function CreateDeliveryPage() {
return (
<div>
<div className="space-y-6 py-6">
<CreateDeliveryForm />
<CurrencyConverter />
</div>
);
}
}
199 changes: 199 additions & 0 deletions features/deliveries/components/CreateDeliveryForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
'use client';

import { useCreateDelivery } from '@/hooks/useCreateDelivery';

export function CreateDeliveryForm() {
const { form, isSubmitting, isSuccess, onSubmit } = useCreateDelivery();

const {
register,
handleSubmit,
formState: { errors, isValid },
} = form;

return (
<section className="rounded-xl border border-secondary/30 bg-white p-6 shadow-sm">
<div className="mb-6">
<h1 className="text-2xl font-semibold">
Create a new logistics request
</h1>
<p className="mt-2 text-sm text-secondary">
Fill in pickup, destination, package details, and recipient contact
information.
</p>
</div>

<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="grid gap-5 lg:grid-cols-2">
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium">Pickup Address</span>
<input
{...register('pickupAddress')}
placeholder="123 Main St, Lagos"
className={`rounded-lg border px-3 py-2 transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.pickupAddress
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300'
}`}
aria-invalid={Boolean(errors.pickupAddress)}
/>
{errors.pickupAddress && (
<span className="text-xs text-red-600">
{errors.pickupAddress.message}
</span>
)}
</label>

<label className="flex flex-col gap-2 text-sm">
<span className="font-medium">Destination</span>
<input
{...register('destination')}
placeholder="456 Victoria Island, Lagos"
className={`rounded-lg border px-3 py-2 transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.destination
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300'
}`}
aria-invalid={Boolean(errors.destination)}
/>
{errors.destination && (
<span className="text-xs text-red-600">
{errors.destination.message}
</span>
)}
</label>
</div>

<div className="grid gap-5 lg:grid-cols-2">
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium">Package Size</span>
<select
{...register('packageSize')}
className={`rounded-lg border px-3 py-2 transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.packageSize
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300'
}`}
aria-invalid={Boolean(errors.packageSize)}
>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
{errors.packageSize && (
<span className="text-xs text-red-600">
{errors.packageSize.message}
</span>
)}
</label>

<label className="flex flex-col gap-2 text-sm">
<span className="font-medium">Description</span>
<textarea
{...register('description')}
rows={4}
placeholder="Describe the package contents and any handling notes"
className={`rounded-lg border px-3 py-2 transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.description
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300'
}`}
aria-invalid={Boolean(errors.description)}
/>
{errors.description && (
<span className="text-xs text-red-600">
{errors.description.message}
</span>
)}
</label>
</div>

<div className="rounded-xl border border-secondary/20 bg-secondary/5 p-4">
<h2 className="text-base font-semibold">Recipient contact details</h2>
<p className="mt-2 text-sm text-secondary">
We need recipient details so the driver can complete the delivery.
</p>

<div className="mt-4 grid gap-5 lg:grid-cols-3">
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium">Recipient Name</span>
<input
{...register('recipientName')}
placeholder="John Doe"
className={`rounded-lg border px-3 py-2 transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.recipientName
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300'
}`}
aria-invalid={Boolean(errors.recipientName)}
/>
{errors.recipientName && (
<span className="text-xs text-red-600">
{errors.recipientName.message}
</span>
)}
</label>

<label className="flex flex-col gap-2 text-sm">
<span className="font-medium">Recipient Phone</span>
<input
{...register('recipientPhone')}
type="tel"
placeholder="+234 801 234 5678"
className={`rounded-lg border px-3 py-2 transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.recipientPhone
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300'
}`}
aria-invalid={Boolean(errors.recipientPhone)}
/>
{errors.recipientPhone && (
<span className="text-xs text-red-600">
{errors.recipientPhone.message}
</span>
)}
</label>

<label className="flex flex-col gap-2 text-sm">
<span className="font-medium">Recipient Email</span>
<input
{...register('recipientEmail')}
type="email"
placeholder="john@example.com"
className={`rounded-lg border px-3 py-2 transition focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.recipientEmail
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300'
}`}
aria-invalid={Boolean(errors.recipientEmail)}
/>
{errors.recipientEmail && (
<span className="text-xs text-red-600">
{errors.recipientEmail.message}
</span>
)}
</label>
</div>
</div>

{isSuccess && (
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
Your delivery request has been submitted successfully.
</div>
)}

<button
type="submit"
disabled={!isValid || isSubmitting}
className={`w-full rounded-xl px-5 py-3 text-white transition ${
isSubmitting || !isValid
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isSubmitting ? 'Submitting request…' : 'Submit delivery request'}
</button>
</form>
</section>
);
}
89 changes: 89 additions & 0 deletions hooks/useCreateDelivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use client';

import { useForm } from 'react-hook-form';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { deliveriesService, type CreateDeliveryPayload } from '@/services/deliveries.service';
import { useToast } from '@/hooks/useToast';

export const createDeliverySchema = z.object({
pickupAddress: z
.string()
.min(5, 'Pickup address is required')
.max(250, 'Pickup address must be under 250 characters'),
destination: z
.string()
.min(5, 'Destination address is required')
.max(250, 'Destination address must be under 250 characters'),
packageSize: z.enum(['small', 'medium', 'large'], {
errorMap: () => ({ message: 'Please select a package size' }),
}),
description: z
.string()
.min(10, 'Package description is required')
.max(500, 'Package description must be under 500 characters'),
recipientName: z.string().min(2, 'Recipient name is required'),
recipientPhone: z
.string()
.min(7, 'Recipient phone is required')
.max(20, 'Recipient phone must be under 20 characters')
.regex(/^[0-9+()\-\.\s]+$/, 'Enter a valid phone number'),
recipientEmail: z.string().email('Enter a valid recipient email'),
});

export type CreateDeliveryFormValues = z.infer<typeof createDeliverySchema>;

export interface UseCreateDeliveryReturn {
form: ReturnType<typeof useForm<CreateDeliveryFormValues>>;
isSubmitting: boolean;
isSuccess: boolean;
onSubmit: (values: CreateDeliveryFormValues) => Promise<void>;
}

export function useCreateDelivery(): UseCreateDeliveryReturn {
const queryClient = useQueryClient();
const { success, error } = useToast();

const form = useForm<CreateDeliveryFormValues>({
resolver: zodResolver(createDeliverySchema),
mode: 'onChange',
defaultValues: {
pickupAddress: '',
destination: '',
packageSize: 'small',
description: '',
recipientName: '',
recipientPhone: '',
recipientEmail: '',
},
});

const createDelivery = useMutation({
mutationFn: (payload: CreateDeliveryPayload) =>
deliveriesService.createDelivery(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['deliveries'] });
success('Delivery request sent', 'Your logistics request was created successfully.');
form.reset();
},
onError: (err) => {
error(
'Unable to create delivery request',
err instanceof Error ? err.message : 'Please try again later',
);
console.error('Create delivery error:', err);
},
});

const onSubmit = async (values: CreateDeliveryFormValues) => {
await createDelivery.mutateAsync(values);
};

return {
form,
isSubmitting: createDelivery.isLoading,
isSuccess: createDelivery.isSuccess,
onSubmit,
};
}
12 changes: 11 additions & 1 deletion services/deliveries.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { apiClient } from './api';
import { Delivery, StatusTimeline } from '../types/delivery';

export interface CreateDeliveryPayload {
pickupAddress: string;
destination: string;
packageSize: 'small' | 'medium' | 'large';
description: string;
recipientName: string;
recipientPhone: string;
recipientEmail: string;
}

export const deliveriesService = {
getDeliveries: async (): Promise<Delivery[]> => {
const { data } = await apiClient.get<Delivery[]>('/deliveries');
return data;
},

getDeliveryById: async (id: string): Promise<Delivery> => {
const { data } = await apiClient.get<Delivery>(`/deliveries/${id}`);
return data;
Expand Down
Loading