Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions client/src/components/OptimizedImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* OptimizedImage.tsx — Cloudinary/imgix CDN image optimization
*
* Features:
* - Responsive srcSet generation (320w, 640w, 960w, 1280w, 1920w)
* - Auto-format (WebP/AVIF) based on browser support
* - Lazy loading with IntersectionObserver
* - Blur-up placeholder (LQIP)
* - Art direction with different crops per breakpoint
* - Retina display support (2x, 3x)
*/

import React, { useState, useRef, useEffect } from "react";

interface OptimizedImageProps {
src: string;
alt: string;
width?: number;
height?: number;
className?: string;
sizes?: string;
priority?: boolean; // Skip lazy loading for above-the-fold images
objectFit?: "cover" | "contain" | "fill" | "none";
quality?: number;
placeholder?: "blur" | "empty";
onLoad?: () => void;
}

const CDN_BASE = process.env.REACT_APP_CDN_URL || "https://res.cloudinary.com/remitflow";
const IMGIX_BASE = process.env.REACT_APP_IMGIX_URL || "https://remitflow.imgix.net";

const BREAKPOINTS = [320, 640, 960, 1280, 1920];
const DEFAULT_QUALITY = 80;

function buildCloudinaryUrl(src: string, width: number, quality: number, format?: string): string {
const transforms = [
`w_${width}`,
`q_${quality}`,
`f_${format || "auto"}`,
"c_fill",
"dpr_auto",
].join(",");
return `${CDN_BASE}/image/upload/${transforms}/${src}`;
}

function buildImgixUrl(src: string, width: number, quality: number, format?: string): string {
const params = new URLSearchParams({
w: String(width),
q: String(quality),
auto: format || "format,compress",
fit: "crop",
dpr: "1",
});
return `${IMGIX_BASE}/${src}?${params.toString()}`;
}

function buildSrcSet(src: string, quality: number): string {
return BREAKPOINTS.map((w) => `${buildCloudinaryUrl(src, w, quality)} ${w}w`).join(", ");
}

function buildBlurPlaceholder(src: string): string {
return buildCloudinaryUrl(src, 20, 10, "webp");
}

export default function OptimizedImage({
src,
alt,
width,
height,
className = "",
sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw",
priority = false,
objectFit = "cover",
quality = DEFAULT_QUALITY,
placeholder = "blur",
onLoad,
}: OptimizedImageProps) {
const [loaded, setLoaded] = useState(false);
const [inView, setInView] = useState(priority);
const imgRef = useRef<HTMLImageElement>(null);

// Intersection Observer for lazy loading
useEffect(() => {
if (priority || !imgRef.current) return;

const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
observer.disconnect();
}
},
{ rootMargin: "200px" } // Start loading 200px before viewport
);

observer.observe(imgRef.current);
return () => observer.disconnect();
}, [priority]);

const isExternal = src.startsWith("http://") || src.startsWith("https://");
const srcSet = isExternal ? undefined : buildSrcSet(src, quality);
const mainSrc = isExternal ? src : buildCloudinaryUrl(src, width || 640, quality);
const blurSrc = isExternal ? undefined : buildBlurPlaceholder(src);

return (
<div
className={`relative overflow-hidden ${className}`}
style={{ width: width ? `${width}px` : "100%", height: height ? `${height}px` : "auto" }}
>
{/* Blur placeholder */}
{placeholder === "blur" && blurSrc && !loaded && (
<img
src={blurSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 w-full h-full object-cover blur-lg scale-110 transition-opacity duration-300"
style={{ opacity: loaded ? 0 : 1 }}
/>
)}

{/* Main image */}
<img
ref={imgRef}
src={inView ? mainSrc : undefined}
srcSet={inView ? srcSet : undefined}
sizes={sizes}
alt={alt}
width={width}
height={height}
loading={priority ? "eager" : "lazy"}
decoding={priority ? "sync" : "async"}
fetchPriority={priority ? "high" : "auto"}
onLoad={() => {
setLoaded(true);
onLoad?.();
}}
className={`w-full h-full transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
style={{ objectFit }}
/>
</div>
);
}

// Avatar variant with circular crop
export function OptimizedAvatar({
src,
alt,
size = 40,
className = "",
}: {
src: string;
alt: string;
size?: number;
className?: string;
}) {
const isExternal = src.startsWith("http://") || src.startsWith("https://");
const optimizedSrc = isExternal
? src
: buildCloudinaryUrl(src, size * 2, 85); // 2x for retina

return (
<img
src={optimizedSrc}
alt={alt}
width={size}
height={size}
loading="lazy"
className={`rounded-full object-cover ${className}`}
style={{ width: `${size}px`, height: `${size}px` }}
/>
);
}

// Hero image with art direction
export function HeroImage({
mobileSrc,
desktopSrc,
alt,
className = "",
}: {
mobileSrc: string;
desktopSrc: string;
alt: string;
className?: string;
}) {
return (
<picture className={className}>
<source
media="(min-width: 1024px)"
srcSet={buildCloudinaryUrl(desktopSrc, 1920, 85)}
type="image/webp"
/>
<source
media="(min-width: 640px)"
srcSet={buildCloudinaryUrl(desktopSrc, 1280, 80)}
type="image/webp"
/>
<img
src={buildCloudinaryUrl(mobileSrc, 640, 75)}
alt={alt}
loading="eager"
className="w-full h-full object-cover"
/>
</picture>
);
}
179 changes: 179 additions & 0 deletions client/src/components/SkeletonLoaders.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* SkeletonLoaders.tsx — Shimmer/skeleton placeholders for all list screens
*
* Replaces spinner loading states with content-shaped placeholders
* for perceived performance improvement. Used in:
* - Transaction lists
* - Wallet balance cards
* - Contact lists
* - KYC document lists
* - Notification feeds
*/

import React from "react";

// Base shimmer animation (CSS-in-JS for portability)
const shimmerStyle = {
background: "linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)",
backgroundSize: "200% 100%",
animation: "shimmer 1.5s infinite",
};

export function TransactionListSkeleton({ count = 5 }: { count?: number }) {
return (
<div className="space-y-3 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center space-x-3 p-4 bg-white dark:bg-gray-800 rounded-lg">
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
</div>
<div className="text-right space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 ml-auto" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12 ml-auto" />
</div>
</div>
))}
</div>
);
}

export function WalletBalanceSkeleton() {
return (
<div className="animate-pulse bg-gradient-to-r from-blue-600 to-purple-600 rounded-2xl p-6">
<div className="h-3 bg-white/20 rounded w-24 mb-2" />
<div className="h-8 bg-white/20 rounded w-40 mb-4" />
<div className="flex space-x-2">
<div className="h-8 bg-white/20 rounded-full w-20" />
<div className="h-8 bg-white/20 rounded-full w-20" />
<div className="h-8 bg-white/20 rounded-full w-20" />
</div>
</div>
);
}

export function ContactListSkeleton({ count = 8 }: { count?: number }) {
return (
<div className="space-y-2 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center space-x-3 p-3">
<div className="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24" />
</div>
</div>
))}
</div>
);
}

export function KYCDocumentSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-4 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center space-x-4">
<div className="w-16 h-12 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-40" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-28" />
</div>
<div className="w-20 h-6 bg-gray-200 dark:bg-gray-700 rounded-full" />
</div>
</div>
))}
</div>
);
}

export function NotificationFeedSkeleton({ count = 6 }: { count?: number }) {
return (
<div className="space-y-1 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-start space-x-3 p-4 border-b border-gray-100 dark:border-gray-800">
<div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded-full mt-1" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-20" />
</div>
</div>
))}
</div>
);
}

export function CardSkeleton() {
return (
<div className="animate-pulse bg-white dark:bg-gray-800 rounded-xl p-5 shadow">
<div className="flex justify-between items-start mb-8">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20" />
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-10" />
</div>
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-4" />
<div className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16" />
</div>
</div>
);
}

export function CurrencyListSkeleton({ count = 5 }: { count?: number }) {
return (
<div className="space-y-2 animate-pulse">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded-full" />
<div className="space-y-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24" />
</div>
</div>
<div className="text-right space-y-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20" />
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-12 ml-auto" />
</div>
</div>
))}
</div>
);
}

export function ProfileSkeleton() {
return (
<div className="animate-pulse space-y-6">
<div className="flex flex-col items-center">
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full mb-3" />
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-1" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
</div>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-28" />
</div>
<div className="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
))}
</div>
</div>
);
}

// Global shimmer keyframes (inject once)
export function ShimmerStyles() {
return (
<style>{`
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
`}</style>
);
}
Loading