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;
issuedAt?: string;
paidDate?: string;
currency?: string | null;
}> = [];
if (invoicesData.status === "fulfilled") {
const invoices: Invoice[] = invoicesData.value.invoices;
@ -446,6 +447,7 @@ export class UsersService {
total: inv.total,
number: inv.number,
issuedAt: inv.issuedAt,
currency: inv.currency ?? null,
}));
} else {
this.logger.error(`Failed to fetch invoices for user ${userId}`, {
@ -459,6 +461,12 @@ export class UsersService {
// Add invoice activities
recentInvoices.forEach(invoice => {
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({
id: `invoice-paid-${invoice.id}`,
type: "invoice_paid",
@ -466,8 +474,16 @@ export class UsersService {
description: `Payment of ¥${invoice.total.toLocaleString()} processed`,
date: invoice.paidDate || invoice.issuedAt || new Date().toISOString(),
relatedId: invoice.id,
metadata,
});
} 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({
id: `invoice-created-${invoice.id}`,
type: "invoice_created",
@ -475,12 +491,18 @@ export class UsersService {
description: `Amount: ¥${invoice.total.toLocaleString()}`,
date: invoice.issuedAt || new Date().toISOString(),
relatedId: invoice.id,
metadata,
});
}
});
// Add subscription activities
recentSubscriptions.forEach(subscription => {
const metadata = {
productName: subscription.productName,
status: subscription.status,
} as Record<string, unknown>;
if (subscription.registrationDate) metadata.registrationDate = subscription.registrationDate;
activities.push({
id: `service-activated-${subscription.id}`,
type: "service_activated",
@ -488,6 +510,7 @@ export class UsersService {
description: "Service successfully provisioned",
date: subscription.registrationDate,
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 { accountService } from "@/features/account/services/account.service";
import { logger } from "@customer-portal/logging";
import { getCountryCodeByName } from "@/lib/constants/countries";
// Use centralized profile types
import type { ProfileEditFormData, Address } from "@customer-portal/domain/customer";
@ -38,32 +39,28 @@ export function useProfileData() {
try {
setLoading(true);
const address = await accountService.getAddress().catch(() => null);
if (address)
setBillingInfo({
address: {
if (address) {
const normalizeCountry = (value?: string | null) => {
if (!value) return "";
if (value.length === 2) return value.toUpperCase();
return getCountryCodeByName(value) ?? value;
};
const normalizedCountry = normalizeCountry(address.country);
const normalizedCountryCode = normalizeCountry(address.countryCode ?? address.country);
const normalizedAddress: Address = {
address1: address.address1 || "",
address2: address.address2 || "",
city: address.city || "",
state: address.state || "",
postcode: address.postcode || "",
country: address.country || "",
countryCode: address.countryCode || "",
country: normalizedCountry,
countryCode: normalizedCountryCode,
phoneNumber: address.phoneNumber || "",
phoneCountryCode: address.phoneCountryCode || "",
},
});
if (address)
setAddress({
address1: address.address1 || "",
address2: address.address2 || "",
city: address.city || "",
state: address.state || "",
postcode: address.postcode || "",
country: address.country || "",
countryCode: address.countryCode || "",
phoneNumber: address.phoneNumber || "",
phoneCountryCode: address.phoneCountryCode || "",
});
};
setBillingInfo({ address: normalizedAddress });
setAddress(normalizedAddress);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load address information");
} finally {

View File

@ -16,7 +16,7 @@ import {
XMarkIcon,
ExclamationTriangleIcon,
} 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
import type { Address } from "@customer-portal/domain/customer";
@ -363,11 +363,11 @@ 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"
>
<option value="">Select Country</option>
<option value="JP">Japan</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="CA">Canada</option>
<option value="AU">Australia</option>
{COUNTRY_OPTIONS.map(option => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>
</div>

View File

@ -2,6 +2,7 @@
import { useEffect } from "react";
import { MapPinIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { COUNTRY_OPTIONS, getCountryCodeByName } from "@/lib/constants/countries";
import { useZodForm } from "@customer-portal/validation";
import {
addressFormSchema,
@ -68,16 +69,7 @@ const DEFAULT_REQUIRED_FIELDS: (keyof Address)[] = [
"country",
];
const 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" },
];
const SELECT_COUNTRY_OPTIONS = [{ code: "", name: "Select Country" }, ...COUNTRY_OPTIONS];
export function AddressForm({
initialAddress = {},
@ -100,14 +92,23 @@ export function AddressForm({
const placeholders = { ...DEFAULT_PLACEHOLDERS, ...fieldPlaceholders };
// 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 = {
address1: initialAddress.address1 || "",
address2: initialAddress.address2 || "",
city: initialAddress.city || "",
state: initialAddress.state || "",
postcode: initialAddress.postcode || "",
country: initialAddress.country || "",
countryCode: initialAddress.countryCode || "",
country: initialCountry,
countryCode: initialCountryCode,
phoneNumber: initialAddress.phoneNumber || "",
phoneCountryCode: initialAddress.phoneCountryCode || "",
};
@ -122,7 +123,15 @@ export function AddressForm({
const handleFieldChange = (field: keyof Address, value: string) => {
if (disabled) return;
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
if (customValidation) {
@ -132,7 +141,15 @@ export function AddressForm({
}
// 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
.filter(f => !hiddenFields.includes(f))
.every(f => updatedValues[f] && String(updatedValues[f]).trim());
@ -147,9 +164,20 @@ export function AddressForm({
if (initialAddress) {
Object.entries(initialAddress).forEach(([key, value]) => {
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]);
@ -191,9 +219,9 @@ export function AddressForm({
className={baseInputClasses}
disabled={disabled}
>
{COUNTRY_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{option.label}
{SELECT_COUNTRY_OPTIONS.map(option => (
<option key={option.code} value={option.code}>
{option.name}
</option>
))}
</select>

View File

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

View File

@ -1,5 +1,6 @@
"use client";
import type { ComponentType, SVGProps } from "react";
import {
DocumentTextIcon,
CheckCircleIcon,
@ -7,39 +8,37 @@ import {
ChatBubbleLeftRightIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import type { Activity } from "@customer-portal/domain/dashboard";
import {
formatActivityDate,
formatActivityDescription,
getActivityIconGradient,
} from "../utils/dashboard.utils";
export function DashboardActivityItem({
id,
type,
title,
description,
date,
onClick,
}: {
id: string | number;
type: string;
title: string;
description: string;
date: string;
interface DashboardActivityItemProps {
activity: Activity;
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";
return (
<Wrapper
key={id}
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" : ""
}`}
@ -54,22 +53,15 @@ export function DashboardActivityItem({
</div>
<div className="flex-1 min-w-0">
<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 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>
</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
*/
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
@ -118,6 +123,84 @@ export function getActivityIconGradient(activityType: Activity["type"]): string
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
*/

View File

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

View File

@ -1,42 +1,51 @@
import countries from "world-countries";
export interface CountryOption {
code: string;
name: string;
}
export const COUNTRY_OPTIONS: CountryOption[] = [
{ code: "US", name: "United States" },
{ code: "CA", name: "Canada" },
{ code: "GB", name: "United Kingdom" },
{ code: "AU", name: "Australia" },
{ code: "DE", name: "Germany" },
{ code: "FR", name: "France" },
{ code: "IT", name: "Italy" },
{ code: "ES", name: "Spain" },
{ code: "NL", name: "Netherlands" },
{ code: "SE", name: "Sweden" },
{ code: "NO", name: "Norway" },
{ code: "DK", name: "Denmark" },
{ code: "FI", name: "Finland" },
{ code: "CH", name: "Switzerland" },
{ 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 normalizedCountries = countries
.filter(country => country.cca2 && country.cca2.length === 2 && country.name?.common)
.map(country => {
const code = country.cca2.toUpperCase();
const commonName = country.name.common;
return {
code,
name: commonName,
searchKeys: [
commonName,
country.name.official,
...(country.altSpellings ?? []),
]
.filter(Boolean)
.map(entry => entry.toLowerCase()),
};
});
const COUNTRY_NAME_BY_CODE = new Map(COUNTRY_OPTIONS.map(option => [option.code, option.name]));
const COUNTRY_CODE_BY_NAME = new Map(
COUNTRY_OPTIONS.map(option => [option.name.toLowerCase(), option.code])
export const COUNTRY_OPTIONS: CountryOption[] = normalizedCountries
.map(({ code, name }) => ({ code, name }))
.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 {
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 {
if (!name) return undefined;
return COUNTRY_CODE_BY_NAME.get(name.toLowerCase()) ?? undefined;
return COUNTRY_CODE_BY_NAME.get(name.toLowerCase());
}