diff --git a/apps/web/components/chuyen-nhuong/transfer-wizard-client.tsx b/apps/web/components/chuyen-nhuong/transfer-wizard-client.tsx
new file mode 100644
index 0000000..8a5b1e2
--- /dev/null
+++ b/apps/web/components/chuyen-nhuong/transfer-wizard-client.tsx
@@ -0,0 +1,337 @@
+'use client';
+
+import { ChevronLeft, ChevronRight, Loader2, Send } from 'lucide-react';
+import { useRouter } from 'next/navigation';
+import * as React from 'react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ type TransferCategory,
+ type TransferCondition,
+ CATEGORY_ICONS,
+ CATEGORY_LABELS,
+ CONDITION_LABELS,
+ transferApi,
+} from '@/lib/chuyen-nhuong-api';
+import { cn } from '@/lib/utils';
+import {
+ type TransferItemDraft,
+ useTransferWizardStore,
+} from '@/lib/transfer-wizard-store';
+
+const STEPS = [
+ 'Danh mục',
+ 'Sản phẩm',
+ 'Định giá AI',
+ 'Thông tin & Gửi',
+] as const;
+
+// ─── Step 1: Category Selection ────────────────────────
+
+function StepCategory() {
+ const { category, setCategory, setStep } = useTransferWizardStore();
+ const categories = Object.entries(CATEGORY_LABELS) as [TransferCategory, string][];
+
+ return (
+
+
Chọn danh mục chuyển nhượng:
+
+ {categories.map(([value, label]) => (
+
+ ))}
+
+
+ );
+}
+
+// ─── Step 2: Items ─────────────────────────────────────
+
+function StepItems() {
+ const { items, addItem, removeItem, category } = useTransferWizardStore();
+ const [name, setName] = React.useState('');
+ const [condition, setCondition] = React.useState('GOOD');
+ const [askingPrice, setAskingPrice] = React.useState('');
+
+ const handleAdd = () => {
+ if (!name || !askingPrice || !category) return;
+ addItem({
+ name,
+ condition,
+ askingPriceVND: Number(askingPrice),
+ quantity: 1,
+ });
+ setName('');
+ setAskingPrice('');
+ };
+
+ return (
+
+
+
+
+ setName(e.target.value)} placeholder="VD: Bàn làm việc" />
+
+
+
+
+
+
+
+ setAskingPrice(e.target.value)} placeholder="0" />
+
+
+
+
+ {items.length > 0 && (
+
+ {items.map((item) => (
+
+
{item.name} — {CONDITION_LABELS[item.condition]}
+
+ {new Intl.NumberFormat('vi-VN').format(item.askingPriceVND)} VND
+
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ─── Step 3: AI Estimate ───────────────────────────────
+
+function StepAiEstimate() {
+ const { items, category, aiEstimate, isEstimating, setAiEstimate, setIsEstimating } = useTransferWizardStore();
+
+ const handleEstimate = async () => {
+ if (!category || items.length === 0) return;
+ setIsEstimating(true);
+ try {
+ const payload = items.map((item) => ({
+ category,
+ condition: item.condition,
+ originalPriceVND: item.askingPriceVND,
+ purchaseYear: new Date().getFullYear(),
+ }));
+ const result = await transferApi.estimate(payload);
+ setAiEstimate(result);
+ } catch {
+ setAiEstimate(null);
+ } finally {
+ setIsEstimating(false);
+ }
+ };
+
+ return (
+
+
+ Sử dụng AI để ước tính giá trị chuyển nhượng dựa trên danh mục, tình trạng và giá gốc.
+
+
+ {aiEstimate && (
+
+
+
+ Tổng ước tính: {new Intl.NumberFormat('vi-VN').format(Number(aiEstimate.totalEstimateVND))} VND
+
+
+ Độ tin cậy: {Math.round(aiEstimate.avgConfidence * 100)}%
+
+
+
+ )}
+
+ );
+}
+
+// ─── Step 4: Details & Submit ──────────────────────────
+
+function StepDetails() {
+ const store = useTransferWizardStore();
+ const router = useRouter();
+ const [submitting, setSubmitting] = React.useState(false);
+
+ const handleSubmit = async () => {
+ if (!store.category) return;
+ setSubmitting(true);
+ try {
+ const payload = {
+ category: store.category,
+ title: store.title,
+ description: store.description || undefined,
+ address: store.address,
+ district: store.district,
+ city: store.city,
+ latitude: 0,
+ longitude: 0,
+ askingPriceVND: store.askingPriceVND || store.items.reduce((sum, i) => sum + i.askingPriceVND * i.quantity, 0),
+ pricingSource: store.pricingSource,
+ isNegotiable: store.isNegotiable,
+ items: store.items.map((i) => ({
+ name: i.name,
+ brand: i.brand,
+ modelName: i.modelName,
+ category: store.category!,
+ condition: i.condition,
+ purchaseYear: i.purchaseYear,
+ originalPriceVND: i.askingPriceVND,
+ askingPriceVND: i.askingPriceVND,
+ quantity: i.quantity,
+ notes: i.notes,
+ })),
+ };
+ await transferApi.create(payload);
+ store.reset();
+ router.push('/chuyen-nhuong');
+ } catch {
+ // Error handling deferred to toast integration
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+// ─── Main Wizard ───────────────────────────────────────
+
+export function TransferWizardClient() {
+ const { currentStep, setStep, items, category } = useTransferWizardStore();
+
+ const canNext = () => {
+ if (currentStep === 0) return !!category;
+ if (currentStep === 1) return items.length > 0;
+ return true;
+ };
+
+ return (
+
+
+
+ Đăng tin chuyển nhượng
+ {/* Step indicator */}
+
+ {STEPS.map((label, i) => (
+
+ {i > 0 && }
+
+
+ ))}
+
+ {STEPS[currentStep]}
+
+
+ {currentStep === 0 && }
+ {currentStep === 1 && }
+ {currentStep === 2 && }
+ {currentStep === 3 && }
+
+ {/* Navigation */}
+
+
+ {currentStep < 3 && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/web/lib/validations/transfer.ts b/apps/web/lib/validations/transfer.ts
new file mode 100644
index 0000000..e2a715b
--- /dev/null
+++ b/apps/web/lib/validations/transfer.ts
@@ -0,0 +1,76 @@
+import { z } from 'zod';
+
+// ─── Step 1: Category Selection ─────────────────────────
+
+export const TRANSFER_CATEGORIES = [
+ { value: 'FURNITURE', label: 'Nội thất', icon: '🛋️' },
+ { value: 'APPLIANCE', label: 'Thiết bị gia dụng', icon: '🧊' },
+ { value: 'OFFICE_EQUIPMENT', label: 'Thiết bị văn phòng', icon: '🖥️' },
+ { value: 'KITCHEN', label: 'Bếp & thiết bị', icon: '🍳' },
+ { value: 'PREMISES', label: 'Mặt bằng', icon: '🏪' },
+ { value: 'FULL_UNIT', label: 'Trọn bộ', icon: '🏠' },
+] as const;
+
+export const TRANSFER_CONDITIONS = [
+ { value: 'NEW', label: 'Mới' },
+ { value: 'LIKE_NEW', label: 'Như mới' },
+ { value: 'GOOD', label: 'Tốt' },
+ { value: 'FAIR', label: 'Khá' },
+ { value: 'WORN', label: 'Cũ' },
+] as const;
+
+export const transferCategorySchema = z.object({
+ category: z.enum(['FURNITURE', 'APPLIANCE', 'OFFICE_EQUIPMENT', 'KITCHEN', 'PREMISES', 'FULL_UNIT'], {
+ error: 'Vui lòng chọn danh mục',
+ }),
+});
+
+// ─── Step 2: Items ──────────────────────────────────────
+
+export const transferItemSchema = z.object({
+ name: z.string().min(1, 'Tên sản phẩm là bắt buộc'),
+ brand: z.string().optional(),
+ modelName: z.string().optional(),
+ condition: z.enum(['NEW', 'LIKE_NEW', 'GOOD', 'FAIR', 'WORN'], {
+ error: 'Vui lòng chọn tình trạng',
+ }),
+ purchaseYear: z.coerce.number().int().min(2000).max(new Date().getFullYear()).optional(),
+ originalPriceVND: z.coerce.number().positive('Giá phải lớn hơn 0').optional(),
+ askingPriceVND: z.coerce.number().positive('Giá phải lớn hơn 0'),
+ quantity: z.coerce.number().int().min(1).default(1),
+ notes: z.string().optional(),
+});
+
+export type TransferItemFormData = z.infer;
+
+// For premises-specific fields
+export const premisesFieldsSchema = z.object({
+ areaM2: z.coerce.number().positive('Diện tích phải lớn hơn 0').optional(),
+ monthlyRentVND: z.coerce.number().positive().optional(),
+ depositMonths: z.coerce.number().int().min(0).optional(),
+ remainingLeaseMo: z.coerce.number().int().min(0).optional(),
+ businessType: z.string().optional(),
+ footTraffic: z.string().optional(),
+});
+
+// ─── Step 3: Location & Contact ─────────────────────────
+
+export const transferLocationSchema = z.object({
+ title: z.string().min(5, 'Tiêu đề tối thiểu 5 ký tự').max(200, 'Tiêu đề tối đa 200 ký tự'),
+ description: z.string().optional(),
+ address: z.string().min(1, 'Địa chỉ là bắt buộc'),
+ ward: z.string().optional(),
+ district: z.string().min(1, 'Quận/Huyện là bắt buộc'),
+ city: z.string().min(1, 'Thành phố là bắt buộc').default('Hồ Chí Minh'),
+ contactName: z.string().optional(),
+ contactPhone: z.string().optional(),
+ isNegotiable: z.boolean().default(true),
+});
+
+// ─── Full form schema ───────────────────────────────────
+
+export const createTransferListingSchema = transferCategorySchema
+ .merge(transferLocationSchema)
+ .merge(premisesFieldsSchema);
+
+export type CreateTransferFormData = z.infer;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1d0b85a..6c29545 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1122,6 +1122,7 @@ enum TransferListingStatus {
SOLD
EXPIRED
REJECTED
+ CANCELLED
}
enum TransferPricingSource {