feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
41
apps/web/lib/inquiry-store.ts
Normal file
41
apps/web/lib/inquiry-store.ts
Normal 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,
|
||||
})),
|
||||
}));
|
||||
174
apps/web/lib/transfer-wizard-store.ts
Normal file
174
apps/web/lib/transfer-wizard-store.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
24
apps/web/lib/validations/inquiry.ts
Normal file
24
apps/web/lib/validations/inquiry.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Vietnamese phone number rule:
|
||||
* - 9–11 digits, optional leading +84 or 0.
|
||||
* We keep validation pragmatic: whitespace is stripped, then the remaining
|
||||
* string must be 9–11 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>;
|
||||
Reference in New Issue
Block a user