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:
parent
2de2e8ec8a
commit
afa0c5306b
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 };
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user