Assist_Design/docs/plans/2026-03-04-trust-strip-redesign.md
barsa a0f97cdec4 style: update background gradients and refactor component references
- 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.
2026-03-04 17:13:07 +09:00

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