feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand';
/**
* UI state for the listing inquiry modal.
*
* Lives in a Zustand store so that:
* - any component (e.g. floating CTAs, sticky "Nhắn tin" bars) can open the
* modal without prop drilling through the listing detail tree
* - tests and devtools can inspect / drive modal state directly
*/
export interface InquiryModalTarget {
listingId: string;
listingTitle: string;
sellerName: string;
}
export interface InquiryModalState {
/** Whether the inquiry modal is currently open. */
isOpen: boolean;
/** The listing being inquired about (null when the modal is closed). */
target: InquiryModalTarget | null;
/** Open the modal for a given listing. */
openInquiry: (target: InquiryModalTarget) => void;
/** Close the modal and clear the active target. */
closeInquiry: () => void;
/** Update open state directly (used by Radix onOpenChange). */
setOpen: (open: boolean) => void;
}
export const useInquiryStore = create<InquiryModalState>((set) => ({
isOpen: false,
target: null,
openInquiry: (target) => set({ isOpen: true, target }),
closeInquiry: () => set({ isOpen: false, target: null }),
setOpen: (open) =>
set((state) => ({
isOpen: open,
target: open ? state.target : null,
})),
}));

View File

@@ -0,0 +1,174 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { TransferCategory, TransferCondition, TransferPricingSource } from './chuyen-nhuong-api';
// ─── Types ──────────────────────────────────────────────
export interface TransferItemDraft {
id: string; // client-side only
name: string;
brand?: string;
modelName?: string;
condition: TransferCondition;
purchaseYear?: number;
originalPriceVND?: number;
askingPriceVND: number;
quantity: number;
notes?: string;
}
export interface AiEstimate {
estimatedPriceVND: string;
confidence: number;
factors: unknown;
}
export interface AiEstimateResult {
estimates: AiEstimate[];
totalEstimateVND: string;
avgConfidence: number;
}
export interface TransferWizardState {
// Step tracking
currentStep: number;
// Step 1: Category
category: TransferCategory | null;
// Step 2: Items
items: TransferItemDraft[];
// Step 2 (premises): Additional fields
areaM2?: number;
monthlyRentVND?: number;
depositMonths?: number;
remainingLeaseMo?: number;
businessType?: string;
footTraffic?: string;
// Step 3: AI estimate
aiEstimate: AiEstimateResult | null;
isEstimating: boolean;
// Step 4: Review & submit
title: string;
description: string;
address: string;
ward: string;
district: string;
city: string;
contactName: string;
contactPhone: string;
askingPriceVND: number;
pricingSource: TransferPricingSource;
isNegotiable: boolean;
// Actions
setStep: (step: number) => void;
setCategory: (category: TransferCategory) => void;
addItem: (item: Omit<TransferItemDraft, 'id'>) => void;
updateItem: (id: string, item: Partial<TransferItemDraft>) => void;
removeItem: (id: string) => void;
setPremisesFields: (fields: Partial<Pick<TransferWizardState, 'areaM2' | 'monthlyRentVND' | 'depositMonths' | 'remainingLeaseMo' | 'businessType' | 'footTraffic'>>) => void;
setAiEstimate: (result: AiEstimateResult | null) => void;
setIsEstimating: (loading: boolean) => void;
setListingDetails: (details: Partial<Pick<TransferWizardState, 'title' | 'description' | 'address' | 'ward' | 'district' | 'city' | 'contactName' | 'contactPhone' | 'askingPriceVND' | 'pricingSource' | 'isNegotiable'>>) => void;
reset: () => void;
}
// ─── Initial state ──────────────────────────────────────
const initialState = {
currentStep: 0,
category: null as TransferCategory | null,
items: [] as TransferItemDraft[],
areaM2: undefined,
monthlyRentVND: undefined,
depositMonths: undefined,
remainingLeaseMo: undefined,
businessType: undefined,
footTraffic: undefined,
aiEstimate: null as AiEstimateResult | null,
isEstimating: false,
title: '',
description: '',
address: '',
ward: '',
district: '',
city: 'Hồ Chí Minh',
contactName: '',
contactPhone: '',
askingPriceVND: 0,
pricingSource: 'MANUAL' as TransferPricingSource,
isNegotiable: true,
};
// ─── Store ──────────────────────────────────────────────
let nextItemId = 1;
export const useTransferWizardStore = create<TransferWizardState>()(
persist(
(set) => ({
...initialState,
setStep: (step) => set({ currentStep: step }),
setCategory: (category) => set({ category }),
addItem: (item) => {
const id = `item-${nextItemId++}`;
set((state) => ({ items: [...state.items, { ...item, id }] }));
},
updateItem: (id, updates) =>
set((state) => ({
items: state.items.map((item) =>
item.id === id ? { ...item, ...updates } : item,
),
})),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
setPremisesFields: (fields) => set(fields),
setAiEstimate: (result) => set({ aiEstimate: result }),
setIsEstimating: (isEstimating) => set({ isEstimating }),
setListingDetails: (details) => set(details),
reset: () => {
nextItemId = 1;
set(initialState);
},
}),
{
name: 'goodgo-transfer-wizard',
partialize: (state) => ({
currentStep: state.currentStep,
category: state.category,
items: state.items,
areaM2: state.areaM2,
monthlyRentVND: state.monthlyRentVND,
depositMonths: state.depositMonths,
remainingLeaseMo: state.remainingLeaseMo,
businessType: state.businessType,
footTraffic: state.footTraffic,
title: state.title,
description: state.description,
address: state.address,
ward: state.ward,
district: state.district,
city: state.city,
contactName: state.contactName,
contactPhone: state.contactPhone,
askingPriceVND: state.askingPriceVND,
pricingSource: state.pricingSource,
isNegotiable: state.isNegotiable,
}),
},
),
);

View File

@@ -0,0 +1,24 @@
import { z } from 'zod';
/**
* Vietnamese phone number rule:
* - 911 digits, optional leading +84 or 0.
* We keep validation pragmatic: whitespace is stripped, then the remaining
* string must be 911 digits (country code / leading zero stripped).
*/
const PHONE_REGEX = /^(?:\+?84|0)?\d{9,11}$/;
export const inquiryFormSchema = z.object({
message: z
.string({ error: 'Vui lòng nhập nội dung tin nhắn' })
.trim()
.min(1, 'Vui lòng nhập nội dung tin nhắn')
.max(2000, 'Tin nhắn không được vượt quá 2000 ký tự'),
phone: z
.string({ error: 'Vui lòng nhập số điện thoại' })
.trim()
.min(9, 'Vui lòng nhập số điện thoại hợp lệ')
.regex(PHONE_REGEX, 'Số điện thoại không hợp lệ'),
});
export type InquiryFormData = z.infer<typeof inquiryFormSchema>;