fix: resolve BFF TypeScript errors and improve mobile UX

BFF fixes:
- Fix pino-http type import by using Params from nestjs-pino
- Use Prisma-generated AuditAction enum instead of local duplicate
- Add null check for sfAccountId in mapping mapper

Portal mobile UX improvements:
- DataTable: Add responsive card view for mobile with stacked layout
- Header: Increase touch targets to 44px minimum, better spacing
- PageLayout: Optimize padding and make breadcrumbs scrollable
- PublicShell: Add iOS safe area support, slide animation, language
  switcher and sign-in button visible in mobile header

Also removes "Trusted by Leading Companies" section from AboutUsView.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Temuuleenn 2026-02-04 18:23:58 +09:00
parent 4c724da7ae
commit df742e50bc
8 changed files with 299 additions and 255 deletions

View File

@ -1,12 +1,13 @@
import { Global, Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { LoggerModule } from "nestjs-pino"; import { LoggerModule, type Params } from "nestjs-pino";
import type { Options as PinoHttpOptions } from "pino-http"; import type { IncomingMessage, ServerResponse } from "http";
const prettyLogsEnabled = const prettyLogsEnabled =
process.env["PRETTY_LOGS"] === "true" || process.env["NODE_ENV"] !== "production"; process.env["PRETTY_LOGS"] === "true" || process.env["NODE_ENV"] !== "production";
// Build pinoHttp config - extracted to avoid type issues with exactOptionalPropertyTypes // Build pinoHttp config - extracted to avoid type issues with exactOptionalPropertyTypes
const pinoHttpConfig: PinoHttpOptions = { // Using NonNullable to extract the pinoHttp type from Params
const pinoHttpConfig: NonNullable<Params["pinoHttp"]> = {
level: process.env["LOG_LEVEL"] || "info", level: process.env["LOG_LEVEL"] || "info",
name: process.env["APP_NAME"] || "customer-portal-bff", name: process.env["APP_NAME"] || "customer-portal-bff",
/** /**
@ -18,13 +19,13 @@ const pinoHttpConfig: PinoHttpOptions = {
* This keeps production logs focused on actionable events while still * This keeps production logs focused on actionable events while still
* allowing full request logging by setting LOG_LEVEL=debug. * allowing full request logging by setting LOG_LEVEL=debug.
*/ */
customLogLevel: (_req, res, err) => { customLogLevel: (_req: IncomingMessage, res: ServerResponse, err: Error | undefined) => {
if (err || (res?.statusCode && res.statusCode >= 500)) return "error"; if (err || (res?.statusCode && res.statusCode >= 500)) return "error";
if (res?.statusCode && res.statusCode >= 400) return "warn"; if (res?.statusCode && res.statusCode >= 400) return "warn";
return "debug"; return "debug";
}, },
autoLogging: { autoLogging: {
ignore: req => { ignore: (req: IncomingMessage) => {
const url = req.url || ""; const url = req.url || "";
return ( return (
url.includes("/health") || url.includes("/health") ||

View File

@ -1,35 +1,12 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { Prisma } from "@prisma/client"; import { Prisma, AuditAction } from "@prisma/client";
import { PrismaService } from "../database/prisma.service.js"; import { PrismaService } from "../database/prisma.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js"; import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js";
export enum AuditAction { // Re-export AuditAction from Prisma for consumers
LOGIN_SUCCESS = "LOGIN_SUCCESS", export { AuditAction } from "@prisma/client";
LOGIN_FAILED = "LOGIN_FAILED",
LOGIN_OTP_SENT = "LOGIN_OTP_SENT",
LOGIN_OTP_VERIFIED = "LOGIN_OTP_VERIFIED",
LOGIN_OTP_FAILED = "LOGIN_OTP_FAILED",
LOGIN_SESSION_INVALIDATED = "LOGIN_SESSION_INVALIDATED",
LOGOUT = "LOGOUT",
SIGNUP = "SIGNUP",
PASSWORD_RESET = "PASSWORD_RESET",
PASSWORD_CHANGE = "PASSWORD_CHANGE",
ACCOUNT_LOCKED = "ACCOUNT_LOCKED",
ACCOUNT_UNLOCKED = "ACCOUNT_UNLOCKED",
PROFILE_UPDATE = "PROFILE_UPDATE",
MFA_ENABLED = "MFA_ENABLED",
MFA_DISABLED = "MFA_DISABLED",
API_ACCESS = "API_ACCESS",
SYSTEM_MAINTENANCE = "SYSTEM_MAINTENANCE",
// Address reconciliation (Salesforce dual-write)
ADDRESS_UPDATE = "ADDRESS_UPDATE",
ADDRESS_UPDATE_PARTIAL = "ADDRESS_UPDATE_PARTIAL",
ADDRESS_RECONCILE_QUEUED = "ADDRESS_RECONCILE_QUEUED",
ADDRESS_RECONCILE_SUCCESS = "ADDRESS_RECONCILE_SUCCESS",
ADDRESS_RECONCILE_FAILED = "ADDRESS_RECONCILE_FAILED",
}
export interface AuditLogData { export interface AuditLogData {
userId?: string | undefined; userId?: string | undefined;

View File

@ -14,6 +14,9 @@ import type { UserIdMapping } from "@bff/modules/id-mappings/domain/index.js";
* Maps Prisma IdMapping entity to Domain UserIdMapping type * Maps Prisma IdMapping entity to Domain UserIdMapping type
*/ */
export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMapping { export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMapping {
if (!mapping.sfAccountId) {
throw new Error(`IdMapping for user ${mapping.userId} is missing sfAccountId`);
}
return { return {
id: mapping.userId, id: mapping.userId,
userId: mapping.userId, userId: mapping.userId,

View File

@ -1,11 +1,16 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { EmptyState } from "@/components/atoms/empty-state"; import { EmptyState } from "@/components/atoms/empty-state";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
interface Column<T> { interface Column<T> {
key: string; key: string;
header: string; header: string;
render: (item: T) => ReactNode; render: (item: T) => ReactNode;
className?: string; className?: string;
/** If true, this column will be emphasized in mobile card view (shown first/larger) */
primary?: boolean;
/** If true, this column will be hidden in mobile card view */
hideOnMobile?: boolean;
} }
interface DataTableProps<T> { interface DataTableProps<T> {
@ -18,6 +23,8 @@ interface DataTableProps<T> {
}; };
onRowClick?: (item: T) => void; onRowClick?: (item: T) => void;
className?: string; className?: string;
/** Force table view even on mobile (not recommended for UX) */
forceTableView?: boolean;
} }
export function DataTable<T extends { id: number | string }>({ export function DataTable<T extends { id: number | string }>({
@ -26,6 +33,7 @@ export function DataTable<T extends { id: number | string }>({
emptyState, emptyState,
onRowClick, onRowClick,
className = "", className = "",
forceTableView = false,
}: DataTableProps<T>) { }: DataTableProps<T>) {
if (data.length === 0 && emptyState) { if (data.length === 0 && emptyState) {
return ( return (
@ -38,43 +46,120 @@ export function DataTable<T extends { id: number | string }>({
); );
} }
// Separate primary column for mobile card header
const primaryColumn = columns.find(col => col.primary);
const mobileColumns = columns.filter(col => !col.hideOnMobile && !col.primary);
return ( return (
<div className="overflow-x-auto"> <>
<table className={`min-w-full divide-y divide-border ${className}`}> {/* Mobile Card View - Hidden on md and up */}
<thead className="bg-muted/50"> {!forceTableView && (
<tr> <div className="md:hidden space-y-3">
{columns.map(column => ( {data.map((item, index) => (
<th <div
key={column.key}
className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider ${
column.className || ""
}`}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-card divide-y divide-border">
{data.map(item => (
<tr
key={item.id} key={item.id}
className={`hover:bg-muted/30 transition-colors duration-[var(--cp-transition-fast)] ${onRowClick ? "cursor-pointer" : ""}`} className={`
bg-card border border-border rounded-xl p-4
shadow-[var(--cp-shadow-1)]
transition-all duration-[var(--cp-duration-fast)]
active:scale-[0.98] active:shadow-none
${onRowClick ? "cursor-pointer active:bg-muted/50" : ""}
`}
onClick={() => onRowClick?.(item)} onClick={() => onRowClick?.(item)}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={e => {
if (onRowClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onRowClick(item);
}
}}
style={{
animationDelay: `${index * 50}ms`,
}}
> >
{/* Primary content row with chevron if clickable */}
<div className="flex items-center justify-between gap-3 mb-3">
<div className="min-w-0 flex-1">
{primaryColumn ? (
<div className="font-semibold text-foreground">
{primaryColumn.render(item)}
</div>
) : (
<div className="font-semibold text-foreground">
{columns[0]?.render(item)}
</div>
)}
</div>
{onRowClick && (
<ChevronRightIcon className="h-5 w-5 text-muted-foreground/50 flex-shrink-0" />
)}
</div>
{/* Secondary columns as label-value pairs */}
<div className="space-y-2">
{mobileColumns.map((column, colIndex) => {
// Skip the first column if no primary is defined (already shown above)
if (!primaryColumn && colIndex === 0 && column === columns[0]) return null;
return (
<div
key={column.key}
className="flex items-center justify-between gap-4 text-sm"
>
<span className="text-muted-foreground font-medium flex-shrink-0">
{column.header}
</span>
<span className="text-foreground text-right min-w-0 truncate">
{column.render(item)}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{/* Desktop Table View - Hidden below md */}
<div className={`${forceTableView ? "" : "hidden md:block"} overflow-x-auto`}>
<table className={`min-w-full divide-y divide-border ${className}`}>
<thead className="bg-muted/50">
<tr>
{columns.map(column => ( {columns.map(column => (
<td <th
key={column.key} key={column.key}
className={`px-6 py-4 whitespace-nowrap ${column.className || ""}`} className={`px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider ${
column.className || ""
}`}
> >
{column.render(item)} {column.header}
</td> </th>
))} ))}
</tr> </tr>
))} </thead>
</tbody> <tbody className="bg-card divide-y divide-border">
</table> {data.map(item => (
</div> <tr
key={item.id}
className={`hover:bg-muted/30 transition-colors duration-[var(--cp-transition-fast)] ${onRowClick ? "cursor-pointer" : ""}`}
onClick={() => onRowClick?.(item)}
>
{columns.map(column => (
<td
key={column.key}
className={`px-6 py-4 whitespace-nowrap ${column.className || ""}`}
>
{column.render(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
); );
} }

View File

@ -40,10 +40,11 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
return ( return (
<div className="relative z-40 bg-header border-b border-header-border/50 backdrop-blur-xl"> <div className="relative z-40 bg-header border-b border-header-border/50 backdrop-blur-xl">
<div className="flex items-center h-16 gap-3 px-4 sm:px-6"> <div className="flex items-center h-16 gap-2 sm:gap-3 px-3 sm:px-6">
{/* Mobile menu button - 44px minimum touch target */}
<button <button
type="button" type="button"
className="md:hidden p-2 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20" className="md:hidden flex items-center justify-center w-11 h-11 -ml-1 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted/80 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
onClick={onMenuClick} onClick={onMenuClick}
aria-label="Open navigation" aria-label="Open navigation"
> >
@ -52,27 +53,35 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
<div className="flex-1" /> <div className="flex-1" />
<div className="flex items-center gap-1"> {/* Right side actions */}
<div className="flex items-center gap-1 sm:gap-2">
{/* Notification bell */}
<NotificationBell /> <NotificationBell />
{/* Help link - visible on larger screens */}
<Link <Link
href="/account/support" href="/account/support"
prefetch prefetch
aria-label="Help" aria-label="Help"
className="hidden sm:inline-flex p-2.5 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-all duration-200" className="hidden sm:inline-flex items-center justify-center w-11 h-11 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted/80 transition-all duration-200"
title="Support Center" title="Support Center"
> >
<QuestionMarkCircleIcon className="h-5 w-5" /> <QuestionMarkCircleIcon className="h-5 w-5" />
</Link> </Link>
{/* Profile link - enhanced for mobile */}
<Link <Link
href="/account/settings" href="/account/settings"
prefetch prefetch
className="group flex items-center gap-2.5 px-3 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 rounded-xl transition-all duration-200" className="group flex items-center gap-2 sm:gap-2.5 px-2 sm:px-3 py-1.5 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 active:bg-muted/80 rounded-xl transition-all duration-200"
> >
<div className="h-7 w-7 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-xs font-bold text-primary-foreground shadow-sm"> <div className="h-8 w-8 sm:h-7 sm:w-7 rounded-lg bg-gradient-to-br from-primary to-primary-hover flex items-center justify-center text-xs font-bold text-primary-foreground shadow-sm group-hover:shadow-md transition-shadow">
{initials} {initials}
</div> </div>
{/* Show truncated name on mobile, full name on larger screens */}
<span className="hidden xs:inline sm:hidden max-w-[80px] truncate text-sm">
{displayName.split(" ")[0]}
</span>
<span className="hidden sm:inline">{displayName}</span> <span className="hidden sm:inline">{displayName}</span>
</Link> </Link>
</div> </div>

View File

@ -33,34 +33,41 @@ export function PageLayout({
children, children,
}: PageLayoutProps) { }: PageLayoutProps) {
return ( return (
<div className="py-[var(--cp-space-xl)] sm:py-[var(--cp-space-2xl)]" suppressHydrationWarning> <div
className="py-[var(--cp-space-lg)] sm:py-[var(--cp-space-xl)] md:py-[var(--cp-space-2xl)]"
suppressHydrationWarning
>
<div <div
className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-lg)] sm:px-[var(--cp-page-padding)] md:px-8" className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-space-lg)] md:px-8"
suppressHydrationWarning suppressHydrationWarning
> >
{/* Breadcrumbs */} {/* Breadcrumbs - scrollable on mobile */}
{breadcrumbs && breadcrumbs.length > 0 && ( {breadcrumbs && breadcrumbs.length > 0 && (
<nav className="mb-[var(--cp-space-lg)]" aria-label="Breadcrumb" suppressHydrationWarning> <nav
className="mb-[var(--cp-space-md)] sm:mb-[var(--cp-space-lg)] -mx-[var(--cp-space-md)] px-[var(--cp-space-md)] sm:mx-0 sm:px-0 overflow-x-auto scrollbar-none"
aria-label="Breadcrumb"
suppressHydrationWarning
>
<ol <ol
className="flex items-center space-x-2 text-sm text-muted-foreground" className="flex items-center space-x-1 sm:space-x-2 text-sm text-muted-foreground whitespace-nowrap"
suppressHydrationWarning suppressHydrationWarning
> >
{breadcrumbs.map((item, index) => ( {breadcrumbs.map((item, index) => (
<li key={index} className="flex items-center" suppressHydrationWarning> <li key={index} className="flex items-center flex-shrink-0" suppressHydrationWarning>
{index > 0 && ( {index > 0 && (
<ChevronRightIcon className="h-4 w-4 mx-2 text-muted-foreground/50" /> <ChevronRightIcon className="h-4 w-4 mx-1 sm:mx-2 text-muted-foreground/50 flex-shrink-0" />
)} )}
{item.href ? ( {item.href ? (
<Link <Link
href={item.href} href={item.href}
className="hover:text-foreground transition-colors duration-200 truncate" className="hover:text-foreground transition-colors duration-200 py-1 px-0.5 -mx-0.5 rounded"
suppressHydrationWarning suppressHydrationWarning
> >
{item.label} {item.label}
</Link> </Link>
) : ( ) : (
<span <span
className="text-foreground font-medium truncate" className="text-foreground font-medium py-1"
aria-current="page" aria-current="page"
suppressHydrationWarning suppressHydrationWarning
> >
@ -75,17 +82,18 @@ export function PageLayout({
{/* Header */} {/* Header */}
<div <div
className="mb-[var(--cp-space-xl)] sm:mb-[var(--cp-space-2xl)] pb-[var(--cp-space-xl)] sm:pb-[var(--cp-space-2xl)] border-b border-border" className="mb-[var(--cp-space-lg)] sm:mb-[var(--cp-space-xl)] md:mb-[var(--cp-space-2xl)] pb-[var(--cp-space-lg)] sm:pb-[var(--cp-space-xl)] md:pb-[var(--cp-space-2xl)] border-b border-border"
suppressHydrationWarning suppressHydrationWarning
> >
<div <div
className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-[var(--cp-space-lg)] sm:gap-[var(--cp-space-xl)]" className="flex flex-col gap-[var(--cp-space-md)] sm:gap-[var(--cp-space-lg)]"
suppressHydrationWarning suppressHydrationWarning
> >
{/* Title row */}
<div className="flex items-start min-w-0 flex-1" suppressHydrationWarning> <div className="flex items-start min-w-0 flex-1" suppressHydrationWarning>
{icon && ( {icon && (
<div <div
className="h-8 w-8 text-primary mr-[var(--cp-space-lg)] flex-shrink-0 mt-1" className="h-7 w-7 sm:h-8 sm:w-8 text-primary mr-[var(--cp-space-md)] sm:mr-[var(--cp-space-lg)] flex-shrink-0 mt-0.5"
suppressHydrationWarning suppressHydrationWarning
> >
{icon} {icon}
@ -93,14 +101,14 @@ export function PageLayout({
)} )}
<div className="min-w-0 flex-1" suppressHydrationWarning> <div className="min-w-0 flex-1" suppressHydrationWarning>
<h1 <h1
className="text-2xl sm:text-3xl font-bold text-foreground leading-tight" className="text-xl sm:text-2xl md:text-3xl font-bold text-foreground leading-tight"
suppressHydrationWarning suppressHydrationWarning
> >
{title} {title}
</h1> </h1>
{description && ( {description && (
<p <p
className="text-sm sm:text-base text-muted-foreground mt-1 leading-relaxed" className="text-sm text-muted-foreground mt-1 leading-relaxed line-clamp-2 sm:line-clamp-none"
suppressHydrationWarning suppressHydrationWarning
> >
{description} {description}
@ -108,8 +116,13 @@ export function PageLayout({
)} )}
</div> </div>
</div> </div>
{/* Actions - full width on mobile, stacks buttons */}
{actions && ( {actions && (
<div className="flex-shrink-0 w-full sm:w-auto" suppressHydrationWarning> <div
className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 w-full sm:w-auto [&>*]:w-full [&>*]:sm:w-auto"
suppressHydrationWarning
>
{actions} {actions}
</div> </div>
)} )}
@ -117,7 +130,7 @@ export function PageLayout({
</div> </div>
{/* Content with loading and error states */} {/* Content with loading and error states */}
<div className="space-y-[var(--cp-space-2xl)]" suppressHydrationWarning> <div className="space-y-[var(--cp-space-xl)] sm:space-y-[var(--cp-space-2xl)]" suppressHydrationWarning>
{renderPageContent(loading, error ?? undefined, children, onRetry)} {renderPageContent(loading, error ?? undefined, children, onRetry)}
</div> </div>
</div> </div>
@ -142,20 +155,20 @@ function renderPageContent(
function PageLoadingState() { function PageLoadingState() {
return ( return (
<div className="py-[var(--cp-space-3xl)]"> <div className="py-[var(--cp-space-xl)] sm:py-[var(--cp-space-3xl)]">
<div className="space-y-6"> <div className="space-y-4 sm:space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" /> <Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-6 w-48" /> <Skeleton className="h-6 w-36 sm:w-48" />
<Skeleton className="h-4 w-64" /> <Skeleton className="h-4 w-48 sm:w-64" />
</div> </div>
</div> </div>
<div className="space-y-4"> <div className="space-y-3 sm:space-y-4">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<div <div
key={i} key={i}
className="bg-card border border-border rounded-lg p-4 shadow-[var(--cp-shadow-1)]" className="bg-card border border-border rounded-lg p-3 sm:p-4 shadow-[var(--cp-shadow-1)]"
> >
<Skeleton className="h-4 w-1/2 mb-2" /> <Skeleton className="h-4 w-1/2 mb-2" />
<Skeleton className="h-3 w-3/4" /> <Skeleton className="h-3 w-3/4" />
@ -176,7 +189,7 @@ function PageErrorState({ error, onRetry }: PageErrorStateProps) {
const errorMessage = typeof error === "string" ? error : error.message; const errorMessage = typeof error === "string" ? error : error.message;
return ( return (
<div className="py-[var(--cp-space-3xl)]"> <div className="py-[var(--cp-space-xl)] sm:py-[var(--cp-space-3xl)]">
<ErrorState <ErrorState
title="Unable to load page" title="Unable to load page"
message={errorMessage} message={errorMessage}

View File

@ -157,8 +157,8 @@ export function PublicShell({ children }: PublicShellProps) {
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" /> <div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/5 via-background to-background" />
<header className="sticky top-0 z-40 border-b border-border/40 bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/80"> <header className="sticky top-0 z-40 border-b border-border/40 bg-background/95 backdrop-blur-md supports-[backdrop-filter]:bg-background/80">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] h-16 grid grid-cols-[auto_1fr_auto] items-center gap-4"> <div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-page-padding)] h-16 grid grid-cols-[auto_1fr_auto] items-center gap-3 sm:gap-4">
<Link href="/" className="inline-flex items-center gap-2.5 min-w-0 group"> <Link href="/" className="inline-flex items-center gap-2 sm:gap-2.5 min-w-0 group">
<span className="inline-flex items-center justify-center h-9 w-9 rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary/15"> <span className="inline-flex items-center justify-center h-9 w-9 rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary/15">
<Logo size={20} /> <Logo size={20} />
</span> </span>
@ -169,7 +169,7 @@ export function PublicShell({ children }: PublicShellProps) {
</span> </span>
</Link> </Link>
{/* Desktop Navigation */} {/* Desktop Navigation - hidden on mobile, nav content shows on md+ */}
<nav className="hidden md:flex items-center justify-center gap-1 sm:gap-3 text-sm font-semibold text-muted-foreground"> <nav className="hidden md:flex items-center justify-center gap-1 sm:gap-3 text-sm font-semibold text-muted-foreground">
<div <div
ref={servicesDropdownRef} ref={servicesDropdownRef}
@ -263,20 +263,18 @@ export function PublicShell({ children }: PublicShellProps) {
</Link> </Link>
</nav> </nav>
{/* Mobile: Language indicator + hamburger */} {/* Spacer for mobile only - takes center column when nav is hidden */}
<div className="flex md:hidden items-center justify-center"> <div className="md:hidden" />
<div className="flex items-center gap-1 text-sm text-muted-foreground mr-2">
<Globe className="h-4 w-4" />
<span className="font-medium">EN</span>
</div>
</div>
<div className="flex items-center gap-3 justify-self-end"> <div className="flex items-center gap-1.5 sm:gap-3 justify-self-end">
{/* Language Selector - Desktop */} {/* Language Selector - All screens */}
<div className="hidden md:flex items-center gap-1 text-sm text-muted-foreground"> <button
type="button"
className="inline-flex items-center gap-1 px-2 sm:px-2.5 py-1.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 active:bg-muted/70 transition-colors"
>
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
<span className="font-medium">EN</span> <span className="font-medium">EN</span>
</div> </button>
{/* Auth Buttons - Desktop */} {/* Auth Buttons - Desktop */}
{isAuthenticated ? ( {isAuthenticated ? (
@ -303,11 +301,28 @@ export function PublicShell({ children }: PublicShellProps) {
</div> </div>
)} )}
{/* Mobile Menu Button */} {/* Auth Button - Mobile (visible in header) */}
{isAuthenticated ? (
<Link
href="/account"
className="md:hidden inline-flex items-center justify-center h-9 px-3 rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 active:bg-primary/80 transition-colors"
>
Account
</Link>
) : (
<Link
href="/auth/login"
className="md:hidden inline-flex items-center justify-center h-9 px-3 rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow hover:bg-primary/90 active:bg-primary/80 transition-colors"
>
Sign in
</Link>
)}
{/* Mobile Menu Button - 44px touch target */}
<button <button
type="button" type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-md hover:bg-muted/50 transition-colors" className="md:hidden inline-flex items-center justify-center w-11 h-11 rounded-xl hover:bg-muted/50 active:bg-muted/70 transition-colors"
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"} aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
> >
@ -317,92 +332,116 @@ export function PublicShell({ children }: PublicShellProps) {
</div> </div>
</header> </header>
{/* Mobile Menu Overlay - Rendered outside header to avoid stacking context issues */} {/* Mobile Menu Overlay - Full screen with slide animation */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div className="md:hidden fixed inset-0 top-16 z-50 bg-white animate-in fade-in duration-200 overflow-y-auto"> <div
<nav className="flex flex-col p-6 space-y-2"> className="md:hidden fixed inset-0 top-16 z-50 bg-background animate-in slide-in-from-right-full duration-300 ease-out overflow-hidden"
<div className="space-y-1"> style={{
// iOS safe area support
paddingBottom: "env(safe-area-inset-bottom, 0px)",
}}
>
<nav className="flex flex-col h-full overflow-y-auto overscroll-contain">
{/* Services section */}
<div className="px-4 pt-4 pb-2">
<p className="text-xs font-semibold text-primary uppercase tracking-wider px-3 py-2"> <p className="text-xs font-semibold text-primary uppercase tracking-wider px-3 py-2">
Services Services
</p> </p>
{serviceItems.map(item => ( <div className="space-y-1">
<Link {serviceItems.map((item, index) => (
key={item.href} <Link
href={item.href} key={item.href}
onClick={closeMobileMenu} href={item.href}
className="flex items-center gap-3 px-3 py-3 rounded-lg hover:bg-muted/50 transition-colors" onClick={closeMobileMenu}
> className="flex items-center gap-3 px-3 py-3.5 rounded-xl hover:bg-muted/50 active:bg-muted/70 transition-colors"
<div style={{
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg ${item.color}`} animationDelay: `${index * 50}ms`,
}}
> >
{item.icon} <div
</div> className={`flex-shrink-0 flex items-center justify-center w-11 h-11 rounded-xl ${item.color}`}
<div> >
<p className="text-sm font-semibold text-foreground">{item.label}</p> {item.icon}
<p className="text-xs text-muted-foreground">{item.desc}</p> </div>
</div> <div className="min-w-0 flex-1">
</Link> <p className="text-base font-semibold text-foreground">{item.label}</p>
))} <p className="text-sm text-muted-foreground">{item.desc}</p>
</div>
</Link>
))}
</div>
</div> </div>
<div className="border-t border-border/40 pt-4 space-y-1"> {/* Other nav links */}
<Link <div className="px-4 py-2 border-t border-border/40">
href="/about" <div className="space-y-1">
onClick={closeMobileMenu} <Link
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors" href="/about"
> onClick={closeMobileMenu}
About className="flex items-center px-3 py-3.5 rounded-xl text-base font-semibold text-foreground hover:bg-muted/50 active:bg-muted/70 transition-colors"
</Link> >
<Link About
href="/blog" </Link>
onClick={closeMobileMenu} <Link
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors" href="/blog"
> onClick={closeMobileMenu}
Blog className="flex items-center px-3 py-3.5 rounded-xl text-base font-semibold text-foreground hover:bg-muted/50 active:bg-muted/70 transition-colors"
</Link> >
<Link Blog
href="/contact" </Link>
onClick={closeMobileMenu} <Link
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors" href="/contact"
> onClick={closeMobileMenu}
Support className="flex items-center px-3 py-3.5 rounded-xl text-base font-semibold text-foreground hover:bg-muted/50 active:bg-muted/70 transition-colors"
</Link> >
Support
</Link>
</div>
</div> </div>
<div className="border-t border-border/40 pt-4 space-y-3"> {/* Auth buttons - sticky at bottom */}
{isAuthenticated ? ( <div
<Link className="mt-auto px-4 py-4 border-t border-border/40 bg-background"
href="/account" style={{
onClick={closeMobileMenu} // Extra bottom padding for iOS home indicator
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors" paddingBottom: "calc(1rem + env(safe-area-inset-bottom, 0px))",
> }}
My Account >
</Link> <div className="space-y-3">
) : ( {isAuthenticated ? (
<>
<Link <Link
href="/auth/get-started" href="/account"
onClick={closeMobileMenu} onClick={closeMobileMenu}
className="flex items-center justify-center rounded-full bg-primary px-6 py-3 text-base font-semibold text-primary-foreground shadow hover:bg-primary/90 transition-colors" className="flex items-center justify-center rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground shadow-lg hover:bg-primary/90 active:bg-primary/80 transition-colors"
> >
Get Started My Account
</Link> </Link>
<Link ) : (
href="/auth/login" <>
onClick={closeMobileMenu} <Link
className="flex items-center justify-center rounded-full border border-input bg-background px-6 py-3 text-base font-semibold text-foreground shadow-sm hover:bg-accent transition-colors" href="/auth/get-started"
> onClick={closeMobileMenu}
Sign in className="flex items-center justify-center rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground shadow-lg hover:bg-primary/90 active:bg-primary/80 transition-colors"
</Link> >
</> Get Started
)} </Link>
<Link
href="/auth/login"
onClick={closeMobileMenu}
className="flex items-center justify-center rounded-full border border-input bg-background px-6 py-3.5 text-base font-semibold text-foreground shadow-sm hover:bg-accent active:bg-accent/80 transition-colors"
>
Sign in
</Link>
</>
)}
</div>
</div> </div>
</nav> </nav>
</div> </div>
)} )}
<main id="main-content" className="flex-1"> <main id="main-content" className="flex-1">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] pt-0 pb-0"> <div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-space-md)] sm:px-[var(--cp-page-padding)] pt-0 pb-0">
{children} {children}
</div> </div>
</main> </main>

View File

@ -23,18 +23,6 @@ import {
* and mission statement for Assist Solutions. * and mission statement for Assist Solutions.
*/ */
export function AboutUsView() { export function AboutUsView() {
// Sample company logos for the trusted by carousel
const trustedCompanies = [
{ name: "Company 1", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 2", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 3", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 4", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 5", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 6", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 7", logo: "/assets/images/placeholder-logo.png" },
{ name: "Company 8", logo: "/assets/images/placeholder-logo.png" },
];
const values = [ const values = [
{ {
text: "Make technology accessible for everyone, regardless of language barriers.", text: "Make technology accessible for everyone, regardless of language barriers.",
@ -170,79 +158,8 @@ export function AboutUsView() {
</div> </div>
</div> </div>
</div> </div>
</section> {/* Gradient fade to Business Solutions section */}
{/* Trusted By Section - Infinite Carousel */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-10 sm:py-12 overflow-hidden">
{/* Gradient fade to next section */}
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#e8f5ff] pointer-events-none z-10" /> <div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-[#e8f5ff] pointer-events-none z-10" />
<div className="max-w-6xl mx-auto px-6 sm:px-8 mb-8">
<h2 className="text-center text-lg sm:text-xl font-semibold text-muted-foreground">
Trusted by Leading Companies
</h2>
</div>
{/* Infinite Carousel */}
<div className="relative">
{/* Gradient masks for fade effect on edges */}
<div className="absolute left-0 top-0 bottom-0 w-24 sm:w-32 bg-gradient-to-r from-white to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 bottom-0 w-24 sm:w-32 bg-gradient-to-l from-white to-transparent z-10 pointer-events-none" />
{/* Scrolling container */}
<div className="flex overflow-hidden">
<div className="flex animate-scroll-infinite gap-12 sm:gap-16">
{/* First set of logos */}
{trustedCompanies.map((company, index) => (
<div
key={`logo-1-${index}`}
className="flex-shrink-0 w-28 h-16 sm:w-36 sm:h-20 flex items-center justify-center grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
>
<Image
src={company.logo}
alt={company.name}
width={120}
height={60}
className="object-contain max-h-full"
/>
</div>
))}
{/* Duplicate set for seamless loop */}
{trustedCompanies.map((company, index) => (
<div
key={`logo-2-${index}`}
className="flex-shrink-0 w-28 h-16 sm:w-36 sm:h-20 flex items-center justify-center grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
>
<Image
src={company.logo}
alt={company.name}
width={120}
height={60}
className="object-contain max-h-full"
/>
</div>
))}
</div>
</div>
</div>
{/* CSS for infinite scroll animation */}
<style jsx>{`
@keyframes scroll-infinite {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.animate-scroll-infinite {
animation: scroll-infinite 30s linear infinite;
}
.animate-scroll-infinite:hover {
animation-play-state: paused;
}
`}</style>
</section> </section>
{/* Business Solutions Carousel */} {/* Business Solutions Carousel */}