Enhance user and address handling by normalizing country data and improving address form functionality. Updated country selection in forms to utilize a dynamic list of countries, ensuring consistency and accuracy. Refactored address processing logic to normalize country codes and names, enhancing user experience and data integrity across the application.

This commit is contained in:
barsa 2025-10-21 13:44:14 +09:00
parent 2de2e8ec8a
commit afa0c5306b
9 changed files with 244 additions and 120 deletions

View File

@ -401,6 +401,7 @@ export class UsersService {
number: string; number: string;
issuedAt?: string; issuedAt?: string;
paidDate?: string; paidDate?: string;
currency?: string | null;
}> = []; }> = [];
if (invoicesData.status === "fulfilled") { if (invoicesData.status === "fulfilled") {
const invoices: Invoice[] = invoicesData.value.invoices; const invoices: Invoice[] = invoicesData.value.invoices;
@ -446,6 +447,7 @@ export class UsersService {
total: inv.total, total: inv.total,
number: inv.number, number: inv.number,
issuedAt: inv.issuedAt, issuedAt: inv.issuedAt,
currency: inv.currency ?? null,
})); }));
} else { } else {
this.logger.error(`Failed to fetch invoices for user ${userId}`, { this.logger.error(`Failed to fetch invoices for user ${userId}`, {
@ -459,6 +461,12 @@ export class UsersService {
// Add invoice activities // Add invoice activities
recentInvoices.forEach(invoice => { recentInvoices.forEach(invoice => {
if (invoice.status === "Paid") { if (invoice.status === "Paid") {
const metadata = {
amount: invoice.total,
currency: invoice.currency ?? "JPY",
} as Record<string, unknown>;
if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
if (invoice.number) metadata.invoiceNumber = invoice.number;
activities.push({ activities.push({
id: `invoice-paid-${invoice.id}`, id: `invoice-paid-${invoice.id}`,
type: "invoice_paid", type: "invoice_paid",
@ -466,8 +474,16 @@ export class UsersService {
description: `Payment of ¥${invoice.total.toLocaleString()} processed`, description: `Payment of ¥${invoice.total.toLocaleString()} processed`,
date: invoice.paidDate || invoice.issuedAt || new Date().toISOString(), date: invoice.paidDate || invoice.issuedAt || new Date().toISOString(),
relatedId: invoice.id, relatedId: invoice.id,
metadata,
}); });
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") { } else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
const metadata = {
amount: invoice.total,
currency: invoice.currency ?? "JPY",
} as Record<string, unknown>;
if (invoice.dueDate) metadata.dueDate = invoice.dueDate;
if (invoice.number) metadata.invoiceNumber = invoice.number;
metadata.status = invoice.status;
activities.push({ activities.push({
id: `invoice-created-${invoice.id}`, id: `invoice-created-${invoice.id}`,
type: "invoice_created", type: "invoice_created",
@ -475,12 +491,18 @@ export class UsersService {
description: `Amount: ¥${invoice.total.toLocaleString()}`, description: `Amount: ¥${invoice.total.toLocaleString()}`,
date: invoice.issuedAt || new Date().toISOString(), date: invoice.issuedAt || new Date().toISOString(),
relatedId: invoice.id, relatedId: invoice.id,
metadata,
}); });
} }
}); });
// Add subscription activities // Add subscription activities
recentSubscriptions.forEach(subscription => { recentSubscriptions.forEach(subscription => {
const metadata = {
productName: subscription.productName,
status: subscription.status,
} as Record<string, unknown>;
if (subscription.registrationDate) metadata.registrationDate = subscription.registrationDate;
activities.push({ activities.push({
id: `service-activated-${subscription.id}`, id: `service-activated-${subscription.id}`,
type: "service_activated", type: "service_activated",
@ -488,6 +510,7 @@ export class UsersService {
description: "Service successfully provisioned", description: "Service successfully provisioned",
date: subscription.registrationDate, date: subscription.registrationDate,
relatedId: subscription.id, relatedId: subscription.id,
metadata,
}); });
}); });

View File

@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react";
import { useAuthStore } from "@/features/auth/services/auth.store"; import { useAuthStore } from "@/features/auth/services/auth.store";
import { accountService } from "@/features/account/services/account.service"; import { accountService } from "@/features/account/services/account.service";
import { logger } from "@customer-portal/logging"; import { logger } from "@customer-portal/logging";
import { getCountryCodeByName } from "@/lib/constants/countries";
// Use centralized profile types // Use centralized profile types
import type { ProfileEditFormData, Address } from "@customer-portal/domain/customer"; import type { ProfileEditFormData, Address } from "@customer-portal/domain/customer";
@ -38,32 +39,28 @@ export function useProfileData() {
try { try {
setLoading(true); setLoading(true);
const address = await accountService.getAddress().catch(() => null); const address = await accountService.getAddress().catch(() => null);
if (address) if (address) {
setBillingInfo({ const normalizeCountry = (value?: string | null) => {
address: { if (!value) return "";
address1: address.address1 || "", if (value.length === 2) return value.toUpperCase();
address2: address.address2 || "", return getCountryCodeByName(value) ?? value;
city: address.city || "", };
state: address.state || "", const normalizedCountry = normalizeCountry(address.country);
postcode: address.postcode || "", const normalizedCountryCode = normalizeCountry(address.countryCode ?? address.country);
country: address.country || "", const normalizedAddress: Address = {
countryCode: address.countryCode || "",
phoneNumber: address.phoneNumber || "",
phoneCountryCode: address.phoneCountryCode || "",
},
});
if (address)
setAddress({
address1: address.address1 || "", address1: address.address1 || "",
address2: address.address2 || "", address2: address.address2 || "",
city: address.city || "", city: address.city || "",
state: address.state || "", state: address.state || "",
postcode: address.postcode || "", postcode: address.postcode || "",
country: address.country || "", country: normalizedCountry,
countryCode: address.countryCode || "", countryCode: normalizedCountryCode,
phoneNumber: address.phoneNumber || "", phoneNumber: address.phoneNumber || "",
phoneCountryCode: address.phoneCountryCode || "", phoneCountryCode: address.phoneCountryCode || "",
}); };
setBillingInfo({ address: normalizedAddress });
setAddress(normalizedAddress);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load address information"); setError(err instanceof Error ? err.message : "Failed to load address information");
} finally { } finally {

View File

@ -16,7 +16,7 @@ import {
XMarkIcon, XMarkIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { getCountryName } from "@/lib/constants/countries"; import { COUNTRY_OPTIONS, getCountryName } from "@/lib/constants/countries";
// Use canonical Address type from domain // Use canonical Address type from domain
import type { Address } from "@customer-portal/domain/customer"; import type { Address } from "@customer-portal/domain/customer";
@ -363,12 +363,12 @@ export function AddressConfirmation({
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<option value="">Select Country</option> <option value="">Select Country</option>
<option value="JP">Japan</option> {COUNTRY_OPTIONS.map(option => (
<option value="US">United States</option> <option key={option.code} value={option.code}>
<option value="GB">United Kingdom</option> {option.name}
<option value="CA">Canada</option> </option>
<option value="AU">Australia</option> ))}
</select> </select>
</div> </div>
<div className="flex items-center space-x-3 pt-4"> <div className="flex items-center space-x-3 pt-4">

View File

@ -2,6 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { MapPinIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { MapPinIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { COUNTRY_OPTIONS, getCountryCodeByName } from "@/lib/constants/countries";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
import { import {
addressFormSchema, addressFormSchema,
@ -68,16 +69,7 @@ const DEFAULT_REQUIRED_FIELDS: (keyof Address)[] = [
"country", "country",
]; ];
const COUNTRY_OPTIONS = [ const SELECT_COUNTRY_OPTIONS = [{ code: "", name: "Select Country" }, ...COUNTRY_OPTIONS];
{ value: "", label: "Select Country" },
{ value: "JP", label: "Japan" },
{ value: "US", label: "United States" },
{ value: "GB", label: "United Kingdom" },
{ value: "CA", label: "Canada" },
{ value: "AU", label: "Australia" },
{ value: "DE", label: "Germany" },
{ value: "FR", label: "France" },
];
export function AddressForm({ export function AddressForm({
initialAddress = {}, initialAddress = {},
@ -100,14 +92,23 @@ export function AddressForm({
const placeholders = { ...DEFAULT_PLACEHOLDERS, ...fieldPlaceholders }; const placeholders = { ...DEFAULT_PLACEHOLDERS, ...fieldPlaceholders };
// Create initial values with proper defaults // Create initial values with proper defaults
const normalizeCountryValue = (value?: string | null) => {
if (!value) return "";
if (value.length === 2) return value.toUpperCase();
return getCountryCodeByName(value) ?? value;
};
const initialCountry = normalizeCountryValue(initialAddress.country);
const initialCountryCode = normalizeCountryValue(initialAddress.countryCode ?? initialCountry);
const initialValues: AddressFormData = { const initialValues: AddressFormData = {
address1: initialAddress.address1 || "", address1: initialAddress.address1 || "",
address2: initialAddress.address2 || "", address2: initialAddress.address2 || "",
city: initialAddress.city || "", city: initialAddress.city || "",
state: initialAddress.state || "", state: initialAddress.state || "",
postcode: initialAddress.postcode || "", postcode: initialAddress.postcode || "",
country: initialAddress.country || "", country: initialCountry,
countryCode: initialAddress.countryCode || "", countryCode: initialCountryCode,
phoneNumber: initialAddress.phoneNumber || "", phoneNumber: initialAddress.phoneNumber || "",
phoneCountryCode: initialAddress.phoneCountryCode || "", phoneCountryCode: initialAddress.phoneCountryCode || "",
}; };
@ -122,7 +123,15 @@ export function AddressForm({
const handleFieldChange = (field: keyof Address, value: string) => { const handleFieldChange = (field: keyof Address, value: string) => {
if (disabled) return; if (disabled) return;
form.setValue(field, value); if (field === "country") {
form.setValue("country", value);
form.setValue("countryCode", value);
} else if (field === "countryCode") {
form.setValue("countryCode", value);
form.setValue("country", value);
} else {
form.setValue(field, value);
}
// Custom validation if provided // Custom validation if provided
if (customValidation) { if (customValidation) {
@ -132,7 +141,15 @@ export function AddressForm({
} }
// Check if address is complete and valid // Check if address is complete and valid
const updatedValues = { ...form.values, [field]: value }; const updatedValues = { ...form.values, [field]: value } as AddressFormData;
if (field === "country") {
updatedValues.country = value;
updatedValues.countryCode = value;
} else if (field === "countryCode") {
updatedValues.countryCode = value;
updatedValues.country = value;
}
const isComplete = requiredFields const isComplete = requiredFields
.filter(f => !hiddenFields.includes(f)) .filter(f => !hiddenFields.includes(f))
.every(f => updatedValues[f] && String(updatedValues[f]).trim()); .every(f => updatedValues[f] && String(updatedValues[f]).trim());
@ -147,9 +164,20 @@ export function AddressForm({
if (initialAddress) { if (initialAddress) {
Object.entries(initialAddress).forEach(([key, value]) => { Object.entries(initialAddress).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
form.setValue(key as keyof Address, value || ""); const normalizedValue =
key === "country" || key === "countryCode"
? normalizeCountryValue(value as string | undefined)
: value || "";
form.setValue(key as keyof Address, normalizedValue);
} }
}); });
const normalizedCountry = normalizeCountryValue(initialAddress.country);
const normalizedCountryCode = normalizeCountryValue(
initialAddress.countryCode ?? initialAddress.country
);
form.setValue("country", normalizedCountry);
form.setValue("countryCode", normalizedCountryCode);
} }
}, [initialAddress]); }, [initialAddress]);
@ -191,9 +219,9 @@ export function AddressForm({
className={baseInputClasses} className={baseInputClasses}
disabled={disabled} disabled={disabled}
> >
{COUNTRY_OPTIONS.map(option => ( {SELECT_COUNTRY_OPTIONS.map(option => (
<option key={option.value} value={option.value}> <option key={option.code} value={option.code}>
{option.label} {option.name}
</option> </option>
))} ))}
</select> </select>

View File

@ -142,11 +142,7 @@ export function ActivityFeed({
return ( return (
<DashboardActivityItem <DashboardActivityItem
key={activity.id} key={activity.id}
id={activity.id} activity={activity}
type={activity.type}
title={activity.title ?? ""}
description={activity.description ?? ""}
date={activity.date}
onClick={clickable ? () => handleActivityClick(activity) : undefined} onClick={clickable ? () => handleActivityClick(activity) : undefined}
/> />
); );

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import type { ComponentType, SVGProps } from "react";
import { import {
DocumentTextIcon, DocumentTextIcon,
CheckCircleIcon, CheckCircleIcon,
@ -7,39 +8,37 @@ import {
ChatBubbleLeftRightIcon, ChatBubbleLeftRightIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import type { Activity } from "@customer-portal/domain/dashboard";
import {
formatActivityDate,
formatActivityDescription,
getActivityIconGradient,
} from "../utils/dashboard.utils";
export function DashboardActivityItem({ interface DashboardActivityItemProps {
id, activity: Activity;
type,
title,
description,
date,
onClick,
}: {
id: string | number;
type: string;
title: string;
description: string;
date: string;
onClick?: () => void; onClick?: () => void;
}) { }
const map: IconMap = {
invoice_created: { icon: DocumentTextIcon, gradient: "from-blue-500 to-cyan-500" },
invoice_paid: { icon: CheckCircleIcon, gradient: "from-green-500 to-emerald-500" },
service_activated: { icon: ServerIcon, gradient: "from-purple-500 to-pink-500" },
case_created: { icon: ChatBubbleLeftRightIcon, gradient: "from-yellow-500 to-orange-500" },
case_closed: { icon: CheckCircleIcon, gradient: "from-green-500 to-emerald-500" },
};
const fallback = { icon: ExclamationTriangleIcon, gradient: "from-gray-500 to-slate-500" };
const conf = map[type as keyof IconMap] ?? fallback;
const Icon = conf.icon;
const gradient = conf.gradient;
const ICON_COMPONENTS: Record<Activity["type"], ComponentType<SVGProps<SVGSVGElement>>> = {
invoice_created: DocumentTextIcon,
invoice_paid: CheckCircleIcon,
service_activated: ServerIcon,
case_created: ChatBubbleLeftRightIcon,
case_closed: CheckCircleIcon,
};
const FALLBACK_ICON = ExclamationTriangleIcon;
export function DashboardActivityItem({ activity, onClick }: DashboardActivityItemProps) {
const Icon = ICON_COMPONENTS[activity.type] ?? FALLBACK_ICON;
const gradient = getActivityIconGradient(activity.type);
const description = formatActivityDescription(activity);
const formattedDate = formatActivityDate(activity.date);
const Wrapper = onClick ? "button" : "div"; const Wrapper = onClick ? "button" : "div";
return ( return (
<Wrapper <Wrapper
key={id}
className={`flex items-start space-x-4 w-full text-left ${ className={`flex items-start space-x-4 w-full text-left ${
onClick ? "p-3 -m-3 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer" : "" onClick ? "p-3 -m-3 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer" : ""
}`} }`}
@ -54,22 +53,15 @@ export function DashboardActivityItem({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p <p
className={`text-sm font-medium ${onClick ? "text-blue-900 group-hover:text-blue-700" : "text-gray-900"}`} className={`text-sm font-medium ${
onClick ? "text-blue-900 group-hover:text-blue-700" : "text-gray-900"
}`}
> >
{title} {activity.title}
</p> </p>
<p className="text-sm text-gray-500 mt-1">{description}</p> <p className="text-sm text-gray-500 mt-1">{description}</p>
<p className="text-xs text-gray-400 mt-2">{date}</p> <p className="text-xs text-gray-400 mt-2">{formattedDate}</p>
</div> </div>
</Wrapper> </Wrapper>
); );
} }
// Local type helper
type IconMap = {
invoice_created: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; gradient: string };
invoice_paid: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; gradient: string };
service_activated: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; gradient: string };
case_created: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; gradient: string };
case_closed: { icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; gradient: string };
};

View File

@ -3,7 +3,12 @@
* Helper functions for dashboard data processing and formatting * Helper functions for dashboard data processing and formatting
*/ */
import type { Activity, ActivityFilter, ActivityFilterConfig } from "@customer-portal/domain/dashboard"; import { z } from "zod";
import type {
Activity,
ActivityFilter,
ActivityFilterConfig,
} from "@customer-portal/domain/dashboard";
/** /**
* Activity filter configurations * Activity filter configurations
@ -118,6 +123,84 @@ export function getActivityIconGradient(activityType: Activity["type"]): string
return gradientMap[activityType] || "from-gray-500 to-slate-500"; return gradientMap[activityType] || "from-gray-500 to-slate-500";
} }
const invoiceActivityMetadataSchema = z
.object({
amount: z.number(),
currency: z.string().optional(),
dueDate: z.string().optional(),
invoiceNumber: z.string().optional(),
status: z.string().optional(),
})
.partial()
.refine(data => typeof data.amount === "number", {
message: "amount is required",
path: ["amount"],
});
const serviceActivityMetadataSchema = z
.object({
productName: z.string().optional(),
registrationDate: z.string().optional(),
status: z.string().optional(),
})
.partial();
const currencyFormatterCache = new Map<string, Intl.NumberFormat>();
const formatCurrency = (amount: number, currency?: string) => {
const code = (currency || "JPY").toUpperCase();
const formatter =
currencyFormatterCache.get(code) ||
(() => {
try {
const intl = new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
});
currencyFormatterCache.set(code, intl);
return intl;
} catch {
return null;
}
})();
if (!formatter) {
return `${code} ${amount.toLocaleString()}`;
}
return formatter.format(amount);
};
export function formatActivityDescription(activity: Activity): string {
switch (activity.type) {
case "invoice_created":
case "invoice_paid": {
const parsed = invoiceActivityMetadataSchema.safeParse(activity.metadata ?? {});
if (parsed.success && typeof parsed.data.amount === "number") {
const formattedAmount = formatCurrency(parsed.data.amount, parsed.data.currency);
if (formattedAmount) {
return activity.type === "invoice_paid"
? `${formattedAmount} payment completed`
: `${formattedAmount} invoice generated`;
}
}
return activity.description ?? "";
}
case "service_activated": {
const parsed = serviceActivityMetadataSchema.safeParse(activity.metadata ?? {});
if (parsed.success && parsed.data.productName) {
return `${parsed.data.productName} is now active`;
}
return activity.description ?? "";
}
case "case_created":
case "case_closed":
return activity.description ?? "";
default:
return activity.description ?? "";
}
}
/** /**
* Truncate text to specified length * Truncate text to specified length
*/ */

View File

@ -370,11 +370,7 @@ function RecentActivityCard({
return ( return (
<DashboardActivityItem <DashboardActivityItem
key={activity.id} key={activity.id}
id={activity.id} activity={activity}
type={activity.type}
title={activity.title ?? ""}
description={activity.description ?? ""}
date={format(new Date(activity.date), "MMM d, yyyy · h:mm a")}
onClick={isClickable ? () => onItemClick(activity) : undefined} onClick={isClickable ? () => onItemClick(activity) : undefined}
/> />
); );

View File

@ -1,42 +1,51 @@
import countries from "world-countries";
export interface CountryOption { export interface CountryOption {
code: string; code: string;
name: string; name: string;
} }
export const COUNTRY_OPTIONS: CountryOption[] = [ const normalizedCountries = countries
{ code: "US", name: "United States" }, .filter(country => country.cca2 && country.cca2.length === 2 && country.name?.common)
{ code: "CA", name: "Canada" }, .map(country => {
{ code: "GB", name: "United Kingdom" }, const code = country.cca2.toUpperCase();
{ code: "AU", name: "Australia" }, const commonName = country.name.common;
{ code: "DE", name: "Germany" }, return {
{ code: "FR", name: "France" }, code,
{ code: "IT", name: "Italy" }, name: commonName,
{ code: "ES", name: "Spain" }, searchKeys: [
{ code: "NL", name: "Netherlands" }, commonName,
{ code: "SE", name: "Sweden" }, country.name.official,
{ code: "NO", name: "Norway" }, ...(country.altSpellings ?? []),
{ code: "DK", name: "Denmark" }, ]
{ code: "FI", name: "Finland" }, .filter(Boolean)
{ code: "CH", name: "Switzerland" }, .map(entry => entry.toLowerCase()),
{ code: "AT", name: "Austria" }, };
{ code: "BE", name: "Belgium" }, });
{ code: "IE", name: "Ireland" },
{ code: "PT", name: "Portugal" },
{ code: "GR", name: "Greece" },
{ code: "JP", name: "Japan" },
];
const COUNTRY_NAME_BY_CODE = new Map(COUNTRY_OPTIONS.map(option => [option.code, option.name])); export const COUNTRY_OPTIONS: CountryOption[] = normalizedCountries
const COUNTRY_CODE_BY_NAME = new Map( .map(({ code, name }) => ({ code, name }))
COUNTRY_OPTIONS.map(option => [option.name.toLowerCase(), option.code]) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
const COUNTRY_NAME_BY_CODE = new Map(
normalizedCountries.map(({ code, name }) => [code, name] as const)
); );
const COUNTRY_CODE_BY_NAME = new Map<string, string>();
normalizedCountries.forEach(({ code, searchKeys }) => {
searchKeys.forEach(key => {
if (key && !COUNTRY_CODE_BY_NAME.has(key)) {
COUNTRY_CODE_BY_NAME.set(key, code);
}
});
});
export function getCountryName(code?: string | null): string | undefined { export function getCountryName(code?: string | null): string | undefined {
if (!code) return undefined; if (!code) return undefined;
return COUNTRY_NAME_BY_CODE.get(code) ?? undefined; return COUNTRY_NAME_BY_CODE.get(code.toUpperCase());
} }
export function getCountryCodeByName(name?: string | null): string | undefined { export function getCountryCodeByName(name?: string | null): string | undefined {
if (!name) return undefined; if (!name) return undefined;
return COUNTRY_CODE_BY_NAME.get(name.toLowerCase()) ?? undefined; return COUNTRY_CODE_BY_NAME.get(name.toLowerCase());
} }