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:
parent
4c724da7ae
commit
df742e50bc
@ -1,12 +1,13 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { LoggerModule } from "nestjs-pino";
|
||||
import type { Options as PinoHttpOptions } from "pino-http";
|
||||
import { LoggerModule, type Params } from "nestjs-pino";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
|
||||
const prettyLogsEnabled =
|
||||
process.env["PRETTY_LOGS"] === "true" || process.env["NODE_ENV"] !== "production";
|
||||
|
||||
// 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",
|
||||
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
|
||||
* 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 (res?.statusCode && res.statusCode >= 400) return "warn";
|
||||
return "debug";
|
||||
},
|
||||
autoLogging: {
|
||||
ignore: req => {
|
||||
ignore: (req: IncomingMessage) => {
|
||||
const url = req.url || "";
|
||||
return (
|
||||
url.includes("/health") ||
|
||||
|
||||
@ -1,35 +1,12 @@
|
||||
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 { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { extractClientIp, extractUserAgent } from "@bff/core/http/request-context.util.js";
|
||||
|
||||
export enum AuditAction {
|
||||
LOGIN_SUCCESS = "LOGIN_SUCCESS",
|
||||
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",
|
||||
}
|
||||
// Re-export AuditAction from Prisma for consumers
|
||||
export { AuditAction } from "@prisma/client";
|
||||
|
||||
export interface AuditLogData {
|
||||
userId?: string | undefined;
|
||||
|
||||
@ -14,6 +14,9 @@ import type { UserIdMapping } from "@bff/modules/id-mappings/domain/index.js";
|
||||
* Maps Prisma IdMapping entity to Domain UserIdMapping type
|
||||
*/
|
||||
export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMapping {
|
||||
if (!mapping.sfAccountId) {
|
||||
throw new Error(`IdMapping for user ${mapping.userId} is missing sfAccountId`);
|
||||
}
|
||||
return {
|
||||
id: mapping.userId,
|
||||
userId: mapping.userId,
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { EmptyState } from "@/components/atoms/empty-state";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render: (item: T) => ReactNode;
|
||||
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> {
|
||||
@ -18,6 +23,8 @@ interface DataTableProps<T> {
|
||||
};
|
||||
onRowClick?: (item: T) => void;
|
||||
className?: string;
|
||||
/** Force table view even on mobile (not recommended for UX) */
|
||||
forceTableView?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<T extends { id: number | string }>({
|
||||
@ -26,6 +33,7 @@ export function DataTable<T extends { id: number | string }>({
|
||||
emptyState,
|
||||
onRowClick,
|
||||
className = "",
|
||||
forceTableView = false,
|
||||
}: DataTableProps<T>) {
|
||||
if (data.length === 0 && emptyState) {
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={`min-w-full divide-y divide-border ${className}`}>
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{columns.map(column => (
|
||||
<th
|
||||
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
|
||||
<>
|
||||
{/* Mobile Card View - Hidden on md and up */}
|
||||
{!forceTableView && (
|
||||
<div className="md:hidden space-y-3">
|
||||
{data.map((item, index) => (
|
||||
<div
|
||||
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)}
|
||||
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 => (
|
||||
<td
|
||||
<th
|
||||
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)}
|
||||
</td>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{data.map(item => (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -40,10 +40,11 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
|
||||
|
||||
return (
|
||||
<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
|
||||
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}
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
@ -52,27 +53,35 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
|
||||
|
||||
<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 />
|
||||
|
||||
{/* Help link - visible on larger screens */}
|
||||
<Link
|
||||
href="/account/support"
|
||||
prefetch
|
||||
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"
|
||||
>
|
||||
<QuestionMarkCircleIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
|
||||
{/* Profile link - enhanced for mobile */}
|
||||
<Link
|
||||
href="/account/settings"
|
||||
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}
|
||||
</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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -33,34 +33,41 @@ export function PageLayout({
|
||||
children,
|
||||
}: PageLayoutProps) {
|
||||
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
|
||||
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
|
||||
>
|
||||
{/* Breadcrumbs */}
|
||||
{/* Breadcrumbs - scrollable on mobile */}
|
||||
{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
|
||||
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
|
||||
>
|
||||
{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 && (
|
||||
<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 ? (
|
||||
<Link
|
||||
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
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="text-foreground font-medium truncate"
|
||||
className="text-foreground font-medium py-1"
|
||||
aria-current="page"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
@ -75,17 +82,18 @@ export function PageLayout({
|
||||
|
||||
{/* Header */}
|
||||
<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
|
||||
>
|
||||
<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
|
||||
>
|
||||
{/* Title row */}
|
||||
<div className="flex items-start min-w-0 flex-1" suppressHydrationWarning>
|
||||
{icon && (
|
||||
<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
|
||||
>
|
||||
{icon}
|
||||
@ -93,14 +101,14 @@ export function PageLayout({
|
||||
)}
|
||||
<div className="min-w-0 flex-1" suppressHydrationWarning>
|
||||
<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
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<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
|
||||
>
|
||||
{description}
|
||||
@ -108,8 +116,13 @@ export function PageLayout({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions - full width on mobile, stacks buttons */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@ -117,7 +130,7 @@ export function PageLayout({
|
||||
</div>
|
||||
|
||||
{/* 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)}
|
||||
</div>
|
||||
</div>
|
||||
@ -142,20 +155,20 @@ function renderPageContent(
|
||||
|
||||
function PageLoadingState() {
|
||||
return (
|
||||
<div className="py-[var(--cp-space-3xl)]">
|
||||
<div className="space-y-6">
|
||||
<div className="py-[var(--cp-space-xl)] sm:py-[var(--cp-space-3xl)]">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-6 w-36 sm:w-48" />
|
||||
<Skeleton className="h-4 w-48 sm:w-64" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
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-3 w-3/4" />
|
||||
@ -176,7 +189,7 @@ function PageErrorState({ error, onRetry }: PageErrorStateProps) {
|
||||
const errorMessage = typeof error === "string" ? error : error.message;
|
||||
|
||||
return (
|
||||
<div className="py-[var(--cp-space-3xl)]">
|
||||
<div className="py-[var(--cp-space-xl)] sm:py-[var(--cp-space-3xl)]">
|
||||
<ErrorState
|
||||
title="Unable to load page"
|
||||
message={errorMessage}
|
||||
|
||||
@ -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" />
|
||||
|
||||
<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">
|
||||
<Link href="/" className="inline-flex items-center gap-2.5 min-w-0 group">
|
||||
<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 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">
|
||||
<Logo size={20} />
|
||||
</span>
|
||||
@ -169,7 +169,7 @@ export function PublicShell({ children }: PublicShellProps) {
|
||||
</span>
|
||||
</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">
|
||||
<div
|
||||
ref={servicesDropdownRef}
|
||||
@ -263,20 +263,18 @@ export function PublicShell({ children }: PublicShellProps) {
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Mobile: Language indicator + hamburger */}
|
||||
<div className="flex md:hidden items-center justify-center">
|
||||
<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>
|
||||
{/* Spacer for mobile only - takes center column when nav is hidden */}
|
||||
<div className="md:hidden" />
|
||||
|
||||
<div className="flex items-center gap-3 justify-self-end">
|
||||
{/* Language Selector - Desktop */}
|
||||
<div className="hidden md:flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 sm:gap-3 justify-self-end">
|
||||
{/* Language Selector - All screens */}
|
||||
<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" />
|
||||
<span className="font-medium">EN</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Auth Buttons - Desktop */}
|
||||
{isAuthenticated ? (
|
||||
@ -303,11 +301,28 @@ export function PublicShell({ children }: PublicShellProps) {
|
||||
</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
|
||||
type="button"
|
||||
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-label={mobileMenuOpen ? "Close menu" : "Open menu"}
|
||||
>
|
||||
@ -317,92 +332,116 @@ export function PublicShell({ children }: PublicShellProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Menu Overlay - Rendered outside header to avoid stacking context issues */}
|
||||
{/* Mobile Menu Overlay - Full screen with slide animation */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden fixed inset-0 top-16 z-50 bg-white animate-in fade-in duration-200 overflow-y-auto">
|
||||
<nav className="flex flex-col p-6 space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
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"
|
||||
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">
|
||||
Services
|
||||
</p>
|
||||
{serviceItems.map(item => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={closeMobileMenu}
|
||||
className="flex items-center gap-3 px-3 py-3 rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg ${item.color}`}
|
||||
<div className="space-y-1">
|
||||
{serviceItems.map((item, index) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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"
|
||||
style={{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">{item.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<div
|
||||
className={`flex-shrink-0 flex items-center justify-center w-11 h-11 rounded-xl ${item.color}`}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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 className="border-t border-border/40 pt-4 space-y-1">
|
||||
<Link
|
||||
href="/about"
|
||||
onClick={closeMobileMenu}
|
||||
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
onClick={closeMobileMenu}
|
||||
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
onClick={closeMobileMenu}
|
||||
className="flex items-center px-3 py-3 rounded-lg text-base font-semibold text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
{/* Other nav links */}
|
||||
<div className="px-4 py-2 border-t border-border/40">
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href="/about"
|
||||
onClick={closeMobileMenu}
|
||||
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"
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog"
|
||||
onClick={closeMobileMenu}
|
||||
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"
|
||||
>
|
||||
Blog
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
onClick={closeMobileMenu}
|
||||
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"
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/40 pt-4 space-y-3">
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href="/account"
|
||||
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"
|
||||
>
|
||||
My Account
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{/* Auth buttons - sticky at bottom */}
|
||||
<div
|
||||
className="mt-auto px-4 py-4 border-t border-border/40 bg-background"
|
||||
style={{
|
||||
// Extra bottom padding for iOS home indicator
|
||||
paddingBottom: "calc(1rem + env(safe-area-inset-bottom, 0px))",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
href="/auth/get-started"
|
||||
href="/account"
|
||||
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
|
||||
href="/auth/login"
|
||||
onClick={closeMobileMenu}
|
||||
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"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/auth/get-started"
|
||||
onClick={closeMobileMenu}
|
||||
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
|
||||
</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>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -23,18 +23,6 @@ import {
|
||||
* and mission statement for Assist Solutions.
|
||||
*/
|
||||
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 = [
|
||||
{
|
||||
text: "Make technology accessible for everyone, regardless of language barriers.",
|
||||
@ -170,79 +158,8 @@ export function AboutUsView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 */}
|
||||
{/* Gradient fade to Business Solutions 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="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>
|
||||
|
||||
{/* Business Solutions Carousel */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user