From afa0c5306b4c7241f2c911facf55e740c0817775 Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 21 Oct 2025 13:44:14 +0900 Subject: [PATCH] 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. --- apps/bff/src/modules/users/users.service.ts | 23 +++++ .../features/account/hooks/useProfileData.ts | 35 ++++---- .../components/base/AddressConfirmation.tsx | 14 +-- .../catalog/components/base/AddressForm.tsx | 64 ++++++++++---- .../dashboard/components/ActivityFeed.tsx | 6 +- .../components/DashboardActivityItem.tsx | 68 +++++++-------- .../dashboard/utils/dashboard.utils.ts | 85 ++++++++++++++++++- .../dashboard/views/DashboardView.tsx | 6 +- apps/portal/src/lib/constants/countries.ts | 63 ++++++++------ 9 files changed, 244 insertions(+), 120 deletions(-) diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index 213ca623..214876d0 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -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; + 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; + 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; + 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, }); }); diff --git a/apps/portal/src/features/account/hooks/useProfileData.ts b/apps/portal/src/features/account/hooks/useProfileData.ts index 2fa77f68..5863b17e 100644 --- a/apps/portal/src/features/account/hooks/useProfileData.ts +++ b/apps/portal/src/features/account/hooks/useProfileData.ts @@ -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: { - 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 || "", - }, - }); - if (address) - setAddress({ + 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 || "", - }); + }; + setBillingInfo({ address: normalizedAddress }); + setAddress(normalizedAddress); + } } catch (err) { setError(err instanceof Error ? err.message : "Failed to load address information"); } finally { diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx index f1cb2b79..3824767c 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx @@ -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,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" > - - - - - - + {COUNTRY_OPTIONS.map(option => ( + + ))} +
diff --git a/apps/portal/src/features/catalog/components/base/AddressForm.tsx b/apps/portal/src/features/catalog/components/base/AddressForm.tsx index 51e230e5..f1e0cdf8 100644 --- a/apps/portal/src/features/catalog/components/base/AddressForm.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressForm.tsx @@ -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; - 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 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 => ( - ))} diff --git a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx index 187465a8..5a5db2ba 100644 --- a/apps/portal/src/features/dashboard/components/ActivityFeed.tsx +++ b/apps/portal/src/features/dashboard/components/ActivityFeed.tsx @@ -142,11 +142,7 @@ export function ActivityFeed({ return ( handleActivityClick(activity) : undefined} /> ); diff --git a/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx b/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx index b1a230a6..0c2f5500 100644 --- a/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx +++ b/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx @@ -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>> = { + 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 (

- {title} + {activity.title}

{description}

-

{date}

+

{formattedDate}

); } - -// Local type helper -type IconMap = { - invoice_created: { icon: React.ComponentType>; gradient: string }; - invoice_paid: { icon: React.ComponentType>; gradient: string }; - service_activated: { icon: React.ComponentType>; gradient: string }; - case_created: { icon: React.ComponentType>; gradient: string }; - case_closed: { icon: React.ComponentType>; gradient: string }; -}; diff --git a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts index a4c4c1fc..d39d2cc5 100644 --- a/apps/portal/src/features/dashboard/utils/dashboard.utils.ts +++ b/apps/portal/src/features/dashboard/utils/dashboard.utils.ts @@ -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(); + +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 */ diff --git a/apps/portal/src/features/dashboard/views/DashboardView.tsx b/apps/portal/src/features/dashboard/views/DashboardView.tsx index b0521ff9..4a0e1b74 100644 --- a/apps/portal/src/features/dashboard/views/DashboardView.tsx +++ b/apps/portal/src/features/dashboard/views/DashboardView.tsx @@ -370,11 +370,7 @@ function RecentActivityCard({ return ( onItemClick(activity) : undefined} /> ); diff --git a/apps/portal/src/lib/constants/countries.ts b/apps/portal/src/lib/constants/countries.ts index af60b877..d1dfbe98 100644 --- a/apps/portal/src/lib/constants/countries.ts +++ b/apps/portal/src/lib/constants/countries.ts @@ -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(); +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()); }