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 { 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") ||
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user