- Replaced oklch color definitions with color-mix for improved gradient consistency across OnsiteSupportContent, HeroSection, and ServicesOverviewContent components. - Refactored ref attributes in multiple sections to remove unnecessary type assertions for better type safety. - Enhanced ServicesCarousel component by introducing a new accent color system for better visual clarity and consistency.
329 lines
9.8 KiB
Markdown
329 lines
9.8 KiB
Markdown
# TrustStrip Redesign — Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace the minimal icon+text trust strip with a bold stats section featuring large animated count-up numbers, icon circles, vertical dividers, and a gradient background.
|
|
|
|
**Architecture:** 3-file change — new `useCountUp` hook for the animation, rewritten `TrustStrip.tsx` component, barrel file update. Uses existing `useInView` hook and codebase patterns (cn utility, Tailwind, lucide-react). No new dependencies.
|
|
|
|
**Tech Stack:** React 19, Tailwind CSS, lucide-react, existing `useInView` hook
|
|
|
|
---
|
|
|
|
### Task 1: Create the useCountUp hook
|
|
|
|
**Files:**
|
|
|
|
- Create: `apps/portal/src/features/landing-page/hooks/useCountUp.ts`
|
|
|
|
**Step 1: Write the hook**
|
|
|
|
Create `apps/portal/src/features/landing-page/hooks/useCountUp.ts`:
|
|
|
|
```tsx
|
|
import { useEffect, useState } from "react";
|
|
|
|
/**
|
|
* useCountUp — Animates a number from 0 to target over a duration.
|
|
* Respects prefers-reduced-motion. Only runs when enabled is true.
|
|
*/
|
|
export function useCountUp(target: number, duration = 1500, enabled = false): number {
|
|
const [value, setValue] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
// Respect prefers-reduced-motion
|
|
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
if (prefersReduced) {
|
|
setValue(target);
|
|
return;
|
|
}
|
|
|
|
let startTime: number | null = null;
|
|
let rafId: number;
|
|
|
|
const animate = (timestamp: number) => {
|
|
if (!startTime) startTime = timestamp;
|
|
const elapsed = timestamp - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Ease-out cubic for smooth deceleration
|
|
const eased = 1 - Math.pow(1 - progress, 3);
|
|
setValue(Math.round(eased * target));
|
|
|
|
if (progress < 1) {
|
|
rafId = requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
rafId = requestAnimationFrame(animate);
|
|
return () => cancelAnimationFrame(rafId);
|
|
}, [target, duration, enabled]);
|
|
|
|
return value;
|
|
}
|
|
```
|
|
|
|
**Step 2: Export from barrel file**
|
|
|
|
Modify `apps/portal/src/features/landing-page/hooks/index.ts` — add this line:
|
|
|
|
```ts
|
|
export { useCountUp } from "./useCountUp";
|
|
```
|
|
|
|
**Step 3: Verify it builds**
|
|
|
|
Run: `pnpm type-check`
|
|
Expected: No errors
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add apps/portal/src/features/landing-page/hooks/useCountUp.ts apps/portal/src/features/landing-page/hooks/index.ts
|
|
git commit -m "feat: add useCountUp hook for animated number transitions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Rewrite TrustStrip component
|
|
|
|
**Files:**
|
|
|
|
- Modify: `apps/portal/src/features/landing-page/components/TrustStrip.tsx` (complete rewrite)
|
|
|
|
**Step 1: Replace TrustStrip.tsx with new implementation**
|
|
|
|
Rewrite `apps/portal/src/features/landing-page/components/TrustStrip.tsx`:
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { Clock, CreditCard, Globe, Users } from "lucide-react";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { cn } from "@/shared/utils";
|
|
import { useInView, useCountUp } from "@/features/landing-page/hooks";
|
|
|
|
interface StatItem {
|
|
icon: LucideIcon;
|
|
value: number;
|
|
suffix: string;
|
|
label: string;
|
|
delay: number;
|
|
formatter?: (n: number) => string;
|
|
}
|
|
|
|
const stats: StatItem[] = [
|
|
{ icon: Clock, value: 20, suffix: "+", label: "Years in Japan", delay: 0 },
|
|
{ icon: Globe, value: 100, suffix: "%", label: "English Support", delay: 100 },
|
|
{
|
|
icon: Users,
|
|
value: 10000,
|
|
suffix: "+",
|
|
label: "Customers Served",
|
|
delay: 200,
|
|
formatter: (n: number) => n.toLocaleString(),
|
|
},
|
|
];
|
|
|
|
function AnimatedStat({ stat, inView }: { stat: StatItem; inView: boolean }) {
|
|
const count = useCountUp(stat.value, 1500, inView);
|
|
|
|
return (
|
|
<span className="text-3xl sm:text-4xl font-extrabold text-primary tabular-nums">
|
|
{stat.formatter ? stat.formatter(count) : count}
|
|
{stat.suffix}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function TrustStrip() {
|
|
const [ref, inView] = useInView();
|
|
|
|
return (
|
|
<section
|
|
ref={ref as React.RefObject<HTMLElement>}
|
|
aria-label="Company statistics"
|
|
className={cn(
|
|
"relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-10 sm:py-12 overflow-hidden transition-all duration-700",
|
|
inView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
|
)}
|
|
>
|
|
{/* Gradient background */}
|
|
<div className="absolute inset-0 bg-gradient-to-r from-surface-sunken via-background to-info-bg/30" />
|
|
|
|
<div className="relative mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
|
<div className="grid grid-cols-2 gap-8 sm:flex sm:justify-between sm:items-center">
|
|
{/* Animated stats */}
|
|
{stats.map((stat, i) => (
|
|
<div
|
|
key={stat.label}
|
|
className={cn(
|
|
"flex flex-col items-center text-center gap-3 sm:flex-1",
|
|
i < stats.length - 1 && "sm:border-r sm:border-border/30"
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
|
|
<stat.icon className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div className="flex flex-col items-center gap-1">
|
|
<AnimatedStat stat={stat} inView={inView} />
|
|
<span className="text-sm text-muted-foreground font-medium">{stat.label}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Static stat — Foreign Cards */}
|
|
<div className={cn("flex flex-col items-center text-center gap-3 sm:flex-1")}>
|
|
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
|
|
<CreditCard className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div className="flex flex-col items-center gap-1">
|
|
<span className="text-xl sm:text-2xl font-extrabold text-primary">Foreign Cards</span>
|
|
<span className="text-sm text-muted-foreground font-medium">Accepted</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
```
|
|
|
|
Key decisions:
|
|
|
|
- `AnimatedStat` is a separate component so each stat has its own `useCountUp` instance
|
|
- The 4th stat (Foreign Cards) is rendered separately since it has no numeric animation
|
|
- Desktop: flex row with `border-r` dividers on first 3 items. Mobile: 2-col grid
|
|
- `tabular-nums` prevents layout jitter during count-up animation
|
|
- `formatter` on the 10,000 stat adds comma separators
|
|
|
|
**Step 2: Verify it builds**
|
|
|
|
Run: `pnpm type-check`
|
|
Expected: No errors
|
|
|
|
**Step 3: Visual check**
|
|
|
|
Open `localhost:3000` and verify:
|
|
|
|
- 4 stats displayed in a row on desktop, 2x2 grid on mobile
|
|
- Numbers animate from 0 to target when scrolling into view
|
|
- Vertical dividers between first 3 stats on desktop
|
|
- "Foreign Cards / Accepted" shows as static bold text
|
|
- Gradient background blends with hero above
|
|
- Each stat has a primary-tinted circular icon above it
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add apps/portal/src/features/landing-page/components/TrustStrip.tsx
|
|
git commit -m "feat: redesign TrustStrip with bold animated stats"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Stagger the count-up animations
|
|
|
|
The current implementation starts all counters simultaneously. Add stagger delays.
|
|
|
|
**Files:**
|
|
|
|
- Modify: `apps/portal/src/features/landing-page/hooks/useCountUp.ts`
|
|
- Modify: `apps/portal/src/features/landing-page/components/TrustStrip.tsx`
|
|
|
|
**Step 1: Add delay parameter to useCountUp**
|
|
|
|
In `useCountUp.ts`, change the signature and add a delay before animation starts:
|
|
|
|
```tsx
|
|
export function useCountUp(target: number, duration = 1500, enabled = false, delay = 0): number {
|
|
```
|
|
|
|
Update the effect body — wrap the animation start in a `setTimeout`:
|
|
|
|
```tsx
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
if (prefersReduced) {
|
|
setValue(target);
|
|
return;
|
|
}
|
|
|
|
let startTime: number | null = null;
|
|
let rafId: number;
|
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
|
|
const animate = (timestamp: number) => {
|
|
if (!startTime) startTime = timestamp;
|
|
const elapsed = timestamp - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const eased = 1 - Math.pow(1 - progress, 3);
|
|
setValue(Math.round(eased * target));
|
|
|
|
if (progress < 1) {
|
|
rafId = requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
timeoutId = setTimeout(() => {
|
|
rafId = requestAnimationFrame(animate);
|
|
}, delay);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
cancelAnimationFrame(rafId);
|
|
};
|
|
}, [target, duration, enabled, delay]);
|
|
```
|
|
|
|
**Step 2: Pass delay from TrustStrip**
|
|
|
|
In `AnimatedStat`, update the `useCountUp` call:
|
|
|
|
```tsx
|
|
function AnimatedStat({ stat, inView }: { stat: StatItem; inView: boolean }) {
|
|
const count = useCountUp(stat.value, 1500, inView, stat.delay);
|
|
```
|
|
|
|
**Step 3: Verify it builds**
|
|
|
|
Run: `pnpm type-check`
|
|
Expected: No errors
|
|
|
|
**Step 4: Visual check**
|
|
|
|
Open `localhost:3000`, scroll to trust strip. The 3 animated numbers should start counting up in sequence: "20+" first, "100%" ~100ms later, "10,000+" ~200ms later. The stagger should feel subtle but add polish.
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/portal/src/features/landing-page/hooks/useCountUp.ts apps/portal/src/features/landing-page/components/TrustStrip.tsx
|
|
git commit -m "feat: add staggered delay to count-up animations"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Final verification
|
|
|
|
**Step 1: Run type check and lint**
|
|
|
|
Run: `pnpm type-check`
|
|
Expected: No errors
|
|
|
|
**Step 2: Visual smoke test**
|
|
|
|
Check `localhost:3000` at:
|
|
|
|
- Desktop (1280px): 4 stats in a row, dividers, gradient bg, count-up animation
|
|
- Tablet (768px): same row, slightly smaller numbers
|
|
- Mobile (375px): 2x2 grid, no dividers, animation still works
|
|
|
|
**Step 3: Accessibility check**
|
|
|
|
- Tab through the page — stats section should be announced as "Company statistics"
|
|
- Set `prefers-reduced-motion: reduce` in browser DevTools — numbers should show final values immediately without animation
|