feat(web): add lib/phone.ts with formatPhone/normalizePhone/zaloHref helpers
- Create apps/web/lib/phone.ts with VN_PHONE_REGEX, normalizePhone, formatPhone, and zaloHref helpers - Deduplicate phone regex: auth.ts and inquiry.ts now import VN_PHONE_REGEX from @/lib/phone instead of defining their own local patterns - Replace raw .replace(/^0/, '84') in inquiry-detail-dialog.tsx and lead-detail-dialog.tsx with zaloHref(); use formatPhone() for display Resolves GOO-209 Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,5 +1,37 @@
|
||||
# Hướng Dẫn Đóng Góp
|
||||
|
||||
## Kỷ Luật Commit & Push (Bắt Buộc)
|
||||
|
||||
> Để tránh conflict khi nhiều agent/engineer làm việc song song, toàn bộ team PHẢI tuân thủ các quy định sau. Nguồn: [GOO-91](/GOO/issues/GOO-91) (chỉ thị từ CEO qua [GOO-88](/GOO/issues/GOO-88)).
|
||||
|
||||
1. **Commit ngay khi hoàn thành task** — mỗi task = một commit (hoặc một chuỗi commit nhỏ liên quan). Không gom nhiều task không liên quan vào một commit lớn.
|
||||
2. **Pull/rebase trước khi push** — luôn chạy `git pull --rebase origin <branch>` trước `git push` để giảm merge conflict.
|
||||
3. **Push ngay sau commit** — không giữ commit local quá 1 ngày làm việc. Commit không push = rủi ro mất việc + conflict tăng.
|
||||
4. **Conventional Commits** — bắt buộc (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `style:`, `perf:`). Xem [Quy Ước Commit](#quy-ước-commit) bên dưới.
|
||||
5. **KHÔNG push trực tiếp lên `main` / `master`** — luôn dùng feature branch + Pull Request. Branch chính được bảo vệ bằng GitHub branch protection rules.
|
||||
6. **PR phải pass CI** (`lint` → `typecheck` → `test` → `build`) trước khi merge. PR đỏ CI không được merge dù đã approve.
|
||||
7. **Squash-merge khi merge PR** — giữ history trên `main` sạch, mỗi PR = một commit logic.
|
||||
8. **Xóa feature branch sau khi merge** — tránh branch sprawl. GitHub có auto-delete branch sau merge; bật nó trong repo settings.
|
||||
|
||||
### Flow nhanh cho mỗi task
|
||||
|
||||
```bash
|
||||
# 1. Tạo/chuyển sang feature branch (KHÔNG commit trực tiếp vào main)
|
||||
git checkout -b feature/goo-xx-short-description
|
||||
|
||||
# 2. Làm việc, khi hoàn thành task:
|
||||
git add <files>
|
||||
git commit -m "feat(scope): mô tả ngắn"
|
||||
|
||||
# 3. Đồng bộ & push
|
||||
git pull --rebase origin main # hoặc develop
|
||||
git push -u origin feature/goo-xx-short-description
|
||||
|
||||
# 4. Mở PR, chờ CI xanh + review, squash-merge, xóa branch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quy Trình Git & Branching
|
||||
|
||||
### Nhánh Chính
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries';
|
||||
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
||||
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||
|
||||
interface InquiryDetailDialogProps {
|
||||
inquiry: InquiryReadDto | null;
|
||||
@@ -42,6 +43,8 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const phone = inquiry.phone ?? inquiry.userPhone;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-lg">
|
||||
@@ -60,7 +63,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
||||
<InquiryStatusBadge isRead={inquiry.isRead} />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>SĐT: {inquiry.phone ?? inquiry.userPhone}</p>
|
||||
<p>SĐT: {formatPhone(phone)}</p>
|
||||
<p>Ngày gửi: {formattedDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,13 +81,13 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
||||
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={`tel:${inquiry.phone ?? inquiry.userPhone}`}
|
||||
href={`tel:${phone}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
||||
</a>
|
||||
<a
|
||||
href={`https://zalo.me/${(inquiry.phone ?? inquiry.userPhone).replace(/^0/, '84')}`}
|
||||
href={zaloHref(phone)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
||||
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
||||
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||
|
||||
interface LeadDetailDialogProps {
|
||||
lead: LeadReadDto | null;
|
||||
@@ -96,7 +97,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>SĐT: {lead.phone}</p>
|
||||
<p>SĐT: {formatPhone(lead.phone)}</p>
|
||||
{lead.email && <p>Email: {lead.email}</p>}
|
||||
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
||||
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
||||
@@ -163,7 +164,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={`https://zalo.me/${lead.phone.replace(/^0/, '84')}`}
|
||||
href={zaloHref(lead.phone)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||
|
||||
59
apps/web/lib/phone.ts
Normal file
59
apps/web/lib/phone.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Vietnamese phone number helpers.
|
||||
*
|
||||
* Regex covers the current VN numbering plan:
|
||||
* 0[35789]x xxxxxxx — Viettel, Mobifone, Vinaphone, Gmobile, Indochina
|
||||
*
|
||||
* See: https://en.wikipedia.org/wiki/Telephone_numbers_in_Vietnam
|
||||
*/
|
||||
|
||||
/** Matches a VN mobile number, with optional +84 or leading 0. */
|
||||
export const VN_PHONE_REGEX =
|
||||
/^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
||||
|
||||
/**
|
||||
* Normalise a VN phone number to the E.164-ish form used by Zalo / APIs:
|
||||
* strip leading 0 and prepend the country code (84).
|
||||
*
|
||||
* "0987654321" → "84987654321"
|
||||
* "+84987654321" → "84987654321"
|
||||
* "84987654321" → "84987654321" (already normalised — idempotent)
|
||||
*/
|
||||
export function normalizePhone(phone: string): string {
|
||||
const cleaned = phone.trim();
|
||||
if (cleaned.startsWith('+84')) return `84${cleaned.slice(3)}`;
|
||||
if (cleaned.startsWith('84')) return cleaned;
|
||||
if (cleaned.startsWith('0')) return `84${cleaned.slice(1)}`;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a raw VN phone number for display.
|
||||
* Handles 10-digit numbers (0xx xxxx xxxx).
|
||||
*
|
||||
* "0987654321" → "0987 654 321"
|
||||
* Passthrough for anything that doesn't match.
|
||||
*/
|
||||
export function formatPhone(phone: string): string {
|
||||
const cleaned = phone.trim().replace(/\s+/g, '');
|
||||
|
||||
// 10-digit local format: 0xxx yyy zzz
|
||||
const tenDigit = cleaned.match(/^(0\d{3})(\d{3})(\d{3})$/);
|
||||
if (tenDigit) return `${tenDigit[1]} ${tenDigit[2]} ${tenDigit[3]}`;
|
||||
|
||||
// +84 prefix → treat as 10-digit local after swapping prefix
|
||||
const e164 = cleaned.match(/^\+84(\d{9})$/);
|
||||
if (e164) return formatPhone(`0${e164[1]}`);
|
||||
|
||||
return phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the https://zalo.me deep-link URL for a given phone number.
|
||||
*
|
||||
* Zalo expects the number without a leading zero, prefixed with 84.
|
||||
* "0987654321" → "https://zalo.me/84987654321"
|
||||
*/
|
||||
export function zaloHref(phone: string): string {
|
||||
return `https://zalo.me/${normalizePhone(phone)}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const phoneRegex = /^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
||||
import { VN_PHONE_REGEX as phoneRegex } from '@/lib/phone';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
phone: z
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
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}$/;
|
||||
import { VN_PHONE_REGEX as PHONE_REGEX } from '@/lib/phone';
|
||||
|
||||
export const inquiryFormSchema = z.object({
|
||||
message: z
|
||||
|
||||
Reference in New Issue
Block a user