diff --git a/apps/portal/src/app/(public)/(site)/services/onsite/OnsiteSupportContent.tsx b/apps/portal/src/app/(public)/(site)/services/onsite/OnsiteSupportContent.tsx index 88b4ceaa..49deaab9 100644 --- a/apps/portal/src/app/(public)/(site)/services/onsite/OnsiteSupportContent.tsx +++ b/apps/portal/src/app/(public)/(site)/services/onsite/OnsiteSupportContent.tsx @@ -111,7 +111,7 @@ export function OnsiteSupportContent() {
@@ -120,13 +120,15 @@ export function OnsiteSupportContent() {
@@ -242,7 +244,7 @@ export function OnsiteSupportContent() {
diff --git a/apps/portal/src/features/landing-page/components/ContactSection.tsx b/apps/portal/src/features/landing-page/components/ContactSection.tsx index 466d3ca1..5d4b7c95 100644 --- a/apps/portal/src/features/landing-page/components/ContactSection.tsx +++ b/apps/portal/src/features/landing-page/components/ContactSection.tsx @@ -11,7 +11,7 @@ export function ContactSection() { return (
} + ref={ref} className={cn( "relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken/30 py-14 sm:py-16 transition-all duration-700", isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" diff --git a/apps/portal/src/features/landing-page/components/HeroSection.tsx b/apps/portal/src/features/landing-page/components/HeroSection.tsx index e7cdfed7..589f0254 100644 --- a/apps/portal/src/features/landing-page/components/HeroSection.tsx +++ b/apps/portal/src/features/landing-page/components/HeroSection.tsx @@ -14,7 +14,7 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) { return (
} + ref={heroRef} className={cn( "relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-16 sm:py-20 lg:py-24 overflow-hidden transition-all duration-700", heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" @@ -28,7 +28,7 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) { className="absolute inset-0 pointer-events-none" aria-hidden="true" style={{ - backgroundImage: `radial-gradient(circle at center, oklch(0.65 0.05 234.4 / 0.15) 1px, transparent 1px)`, + backgroundImage: `radial-gradient(circle at center, color-mix(in oklch, var(--primary) 15%, transparent) 1px, transparent 1px)`, backgroundSize: "24px 24px", }} /> @@ -38,7 +38,8 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) { className="absolute -top-32 -right-32 w-96 h-96 rounded-full pointer-events-none" aria-hidden="true" style={{ - background: "radial-gradient(circle, oklch(0.85 0.08 200 / 0.3) 0%, transparent 70%)", + background: + "radial-gradient(circle, color-mix(in oklch, var(--info) 25%, transparent) 0%, transparent 70%)", }} /> diff --git a/apps/portal/src/features/landing-page/components/ServicesCarousel.tsx b/apps/portal/src/features/landing-page/components/ServicesCarousel.tsx index 4922e2d1..e7de4716 100644 --- a/apps/portal/src/features/landing-page/components/ServicesCarousel.tsx +++ b/apps/portal/src/features/landing-page/components/ServicesCarousel.tsx @@ -1,10 +1,10 @@ "use client"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useEffect, useState } from "react"; import Link from "next/link"; import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/shared/utils"; -import { useInView } from "@/features/landing-page/hooks"; +import { useInfiniteCarousel, useInView } from "@/features/landing-page/hooks"; import { personalConversionCards, businessConversionCards, @@ -14,50 +14,107 @@ import { type Tab = "personal" | "business"; -/* ─── Accent color system (oklch for perceptual uniformity) ─── */ +/* ─── Accent color system ─── */ -interface AccentDef { - l: number; - c: number; - h: number; +interface AccentStyles { + iconBg: string; + iconText: string; + ctaBg: string; + dotBg: string; + borderInactive: string; + borderActive: string; + cssVar: string; } -const ACCENTS = new Map([ - ["blue", { l: 0.55, c: 0.18, h: 240 }], - ["emerald", { l: 0.6, c: 0.17, h: 160 }], - ["violet", { l: 0.55, c: 0.2, h: 290 }], - ["amber", { l: 0.72, c: 0.17, h: 75 }], - ["indigo", { l: 0.5, c: 0.2, h: 265 }], - ["cyan", { l: 0.58, c: 0.14, h: 200 }], - ["rose", { l: 0.58, c: 0.2, h: 15 }], - ["slate", { l: 0.5, c: 0.03, h: 260 }], -]); +const ACCENTS: Record = { + blue: { + iconBg: "bg-blue-500/12", + iconText: "text-blue-600", + ctaBg: "bg-blue-600", + dotBg: "bg-blue-600", + borderInactive: "border-blue-500/10", + borderActive: "border-blue-500/25", + cssVar: "var(--color-blue-500)", + }, + emerald: { + iconBg: "bg-emerald-500/12", + iconText: "text-emerald-600", + ctaBg: "bg-emerald-600", + dotBg: "bg-emerald-600", + borderInactive: "border-emerald-500/10", + borderActive: "border-emerald-500/25", + cssVar: "var(--color-emerald-500)", + }, + violet: { + iconBg: "bg-violet-500/12", + iconText: "text-violet-600", + ctaBg: "bg-violet-600", + dotBg: "bg-violet-600", + borderInactive: "border-violet-500/10", + borderActive: "border-violet-500/25", + cssVar: "var(--color-violet-500)", + }, + amber: { + iconBg: "bg-amber-500/12", + iconText: "text-amber-600", + ctaBg: "bg-amber-600", + dotBg: "bg-amber-600", + borderInactive: "border-amber-500/10", + borderActive: "border-amber-500/25", + cssVar: "var(--color-amber-500)", + }, + indigo: { + iconBg: "bg-indigo-500/12", + iconText: "text-indigo-600", + ctaBg: "bg-indigo-600", + dotBg: "bg-indigo-600", + borderInactive: "border-indigo-500/10", + borderActive: "border-indigo-500/25", + cssVar: "var(--color-indigo-500)", + }, + cyan: { + iconBg: "bg-cyan-500/12", + iconText: "text-cyan-600", + ctaBg: "bg-cyan-600", + dotBg: "bg-cyan-600", + borderInactive: "border-cyan-500/10", + borderActive: "border-cyan-500/25", + cssVar: "var(--color-cyan-500)", + }, + rose: { + iconBg: "bg-rose-500/12", + iconText: "text-rose-600", + ctaBg: "bg-rose-600", + dotBg: "bg-rose-600", + borderInactive: "border-rose-500/10", + borderActive: "border-rose-500/25", + cssVar: "var(--color-rose-500)", + }, + slate: { + iconBg: "bg-slate-500/12", + iconText: "text-slate-600", + ctaBg: "bg-slate-600", + dotBg: "bg-slate-600", + borderInactive: "border-slate-500/10", + borderActive: "border-slate-500/25", + cssVar: "var(--color-slate-500)", + }, +}; -// Safe: all CarouselAccent keys are present in ACCENTS -function getAccent(key: CarouselAccent): AccentDef { - // biome-ignore lint: Map covers all CarouselAccent keys - return ACCENTS.get(key)!; -} - -function oklch(a: AccentDef, alpha?: number) { - const alphaStr = alpha == null ? "" : ` / ${alpha}`; - return `oklch(${a.l} ${a.c} ${a.h}${alphaStr})`; -} - -function accentBg(a: AccentDef) { - return `oklch(0.97 ${(a.c * 0.12).toFixed(3)} ${a.h})`; -} +/* ─── Slide visual styles by distance from center ─── */ const SLIDE_STYLES = [ - { transform: "scale(1)", opacity: 1, filter: "none" }, - { transform: "scale(0.88)", opacity: 0.5, filter: "blur(2px)" }, - { transform: "scale(0.78)", opacity: 0.25, filter: "blur(4px)" }, + { scale: 1, opacity: 1, filter: "none" }, + { scale: 0.88, opacity: 0.5, filter: "blur(2px)" }, + { scale: 0.78, opacity: 0.25, filter: "blur(4px)" }, ] as const; -function slideStyles(offset: number) { - return SLIDE_STYLES[Math.min(offset, 2)]; +function slideStyle(offset: number) { + return SLIDE_STYLES[Math.min(offset, 2)] ?? SLIDE_STYLES[2]!; } +const GAP = 24; + /* ─── Spotlight Card ─── */ const SpotlightCard = memo(function SpotlightCard({ @@ -67,7 +124,7 @@ const SpotlightCard = memo(function SpotlightCard({ card: ConversionServiceCard; isActive: boolean; }) { - const a = getAccent(card.accent); + const a = ACCENTS[card.accent]; return (
- {/* Icon + Badge */}
{card.icon}
@@ -101,8 +161,6 @@ const SpotlightCard = memo(function SpotlightCard({ )}
- - {/* Content */}

{card.problemHook}

{card.title} @@ -110,11 +168,11 @@ const SpotlightCard = memo(function SpotlightCard({

{card.description}

- - {/* CTA Button */} {card.ctaLabel} @@ -124,191 +182,173 @@ const SpotlightCard = memo(function SpotlightCard({ ); }); -/* ─── Main Carousel ─── */ +/* ─── Header + Tab Toggle ─── */ -const AUTO_INTERVAL = 5000; -const GAP = 24; +function CarouselHeader({ + activeTab, + onTabChange, +}: { + activeTab: Tab; + onTabChange: (tab: Tab) => void; +}) { + return ( +
+
+
+

Our Services

+

+ Everything you need to stay connected in Japan +

+
+
+ {(["personal", "business"] as const).map(tab => ( + + ))} +
+
+
+ ); +} + +/* ─── Dots + Arrow Navigation ─── */ + +function CarouselNav({ + cards, + activeIndex, + goTo, + goPrev, + goNext, +}: { + cards: ConversionServiceCard[]; + activeIndex: number; + goTo: (i: number) => void; + goPrev: () => void; + goNext: () => void; +}) { + return ( +
+
+ +
+ {cards.map((card, i) => { + const styles = ACCENTS[card.accent]; + return ( +
+ +
+
+ ); +} + +/* ─── Main Carousel ─── */ export function ServicesCarousel() { const [activeTab, setActiveTab] = useState("personal"); - const [activeIndex, setActiveIndex] = useState(0); - const [cardWidth, setCardWidth] = useState(520); - const autoRef = useRef | null>(null); - const touchXRef = useRef(0); - const rafRef = useRef(0); const [sectionRef, isInView] = useInView(); - const cards = activeTab === "personal" ? personalConversionCards : businessConversionCards; - const total = cards.length; - - // Responsive card width (rAF-throttled) - useEffect(() => { - const update = () => { - const vw = window.innerWidth; - if (vw < 640) setCardWidth(vw - 48); - else if (vw < 1024) setCardWidth(440); - else setCardWidth(520); - }; - const onResize = () => { - cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(update); - }; - update(); - window.addEventListener("resize", onResize); - return () => { - window.removeEventListener("resize", onResize); - cancelAnimationFrame(rafRef.current); - }; - }, []); - - // Auto-rotate - const startAuto = useCallback(() => { - if (autoRef.current) clearInterval(autoRef.current); - autoRef.current = setInterval(() => { - setActiveIndex(prev => (prev + 1) % total); - }, AUTO_INTERVAL); - }, [total]); - - const stopAuto = useCallback(() => { - if (autoRef.current) { - clearInterval(autoRef.current); - autoRef.current = null; - } - }, []); + const c = useInfiniteCarousel({ items: cards }); useEffect(() => { - startAuto(); - return stopAuto; - }, [startAuto, stopAuto]); - - // Reset on tab change - useEffect(() => { - setActiveIndex(0); + c.reset(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab]); - const goTo = useCallback( - (i: number) => { - setActiveIndex(i); - startAuto(); - }, - [startAuto] - ); - - const goPrev = useCallback(() => { - setActiveIndex(p => (p - 1 + total) % total); - startAuto(); - }, [total, startAuto]); - - const goNext = useCallback(() => { - setActiveIndex(p => (p + 1) % total); - startAuto(); - }, [total, startAuto]); - - // Touch swipe - const onTouchStart = useCallback((e: React.TouchEvent) => { - const touch = e.touches[0]; - if (touch) touchXRef.current = touch.clientX; - }, []); - - const onTouchEnd = useCallback( - (e: React.TouchEvent) => { - const touch = e.changedTouches[0]; - if (!touch) return; - const diff = touchXRef.current - touch.clientX; - if (Math.abs(diff) > 50) { - if (diff > 0) goNext(); - else goPrev(); - } - }, - [goNext, goPrev] - ); - - // Keyboard nav - const onKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "ArrowLeft") goPrev(); - else if (e.key === "ArrowRight") goNext(); - }, - [goPrev, goNext] - ); - - const trackX = -(activeIndex * (cardWidth + GAP)); + const trackX = -(c.trackIndex * (c.cardWidth + GAP)); return (
} + ref={sectionRef} className={cn( "relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken/30 py-16 sm:py-20 transition-all duration-700", isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" )} > - {/* Header + Tabs */} -
-
-
-

Our Services

-

- Everything you need to stay connected in Japan -

-
-
- {(["personal", "business"] as const).map(tab => ( - - ))} -
-
-
+ - {/* Spotlight Carousel Track */}
- {cards.map((card, i) => { - const offset = Math.abs(i - activeIndex); + {c.extendedItems.map((card, i) => { + const offset = Math.abs(i - c.trackIndex); + const style = slideStyle(offset); return (
0 && "cursor-pointer" )} role="group" aria-roledescription="slide" - aria-label={`${i + 1} of ${total}: ${card.title}`} + aria-label={`${c.activeIndex + 1} of ${c.total}: ${card.title}`} style={{ - width: cardWidth, - ...slideStyles(offset), + width: c.cardWidth, + transform: `scale(${style.scale})`, + opacity: style.opacity, + filter: style.filter, }} onClick={() => { - if (offset > 0) goTo(i); + if (i < c.trackIndex) c.goPrev(); + else if (i > c.trackIndex) c.goNext(); }} > @@ -318,45 +358,13 @@ export function ServicesCarousel() {
- {/* Navigation: Dots + Arrows */} -
-
- -
- {cards.map((card, i) => { - const a = getAccent(card.accent); - return ( -
- -
-
+
); } diff --git a/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx b/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx index 91455f09..eef0f65d 100644 --- a/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx +++ b/apps/portal/src/features/landing-page/components/SupportDownloadsSection.tsx @@ -11,7 +11,7 @@ export function SupportDownloadsSection() { return (
} + ref={ref} className={cn( "py-14 sm:py-16 transition-all duration-700", isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" diff --git a/apps/portal/src/features/landing-page/components/TrustStrip.tsx b/apps/portal/src/features/landing-page/components/TrustStrip.tsx index 609c664e..e295b4bb 100644 --- a/apps/portal/src/features/landing-page/components/TrustStrip.tsx +++ b/apps/portal/src/features/landing-page/components/TrustStrip.tsx @@ -70,7 +70,7 @@ export function TrustStrip() { return (
} + ref={ref} 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", diff --git a/apps/portal/src/features/landing-page/components/WhyUsSection.tsx b/apps/portal/src/features/landing-page/components/WhyUsSection.tsx index 3d319d64..16b59779 100644 --- a/apps/portal/src/features/landing-page/components/WhyUsSection.tsx +++ b/apps/portal/src/features/landing-page/components/WhyUsSection.tsx @@ -17,7 +17,7 @@ export function WhyUsSection() { return (
} + ref={ref} className={cn( "relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background transition-all duration-700", isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" diff --git a/apps/portal/src/features/landing-page/hooks/index.ts b/apps/portal/src/features/landing-page/hooks/index.ts index 85e5d8ee..da49bb11 100644 --- a/apps/portal/src/features/landing-page/hooks/index.ts +++ b/apps/portal/src/features/landing-page/hooks/index.ts @@ -1,2 +1,3 @@ +export { useInfiniteCarousel } from "./useInfiniteCarousel"; export { useInView } from "./useInView"; export { useStickyCta } from "./useStickyCta"; diff --git a/apps/portal/src/features/landing-page/hooks/useInView.ts b/apps/portal/src/features/landing-page/hooks/useInView.ts index 56d3548c..c9cdadde 100644 --- a/apps/portal/src/features/landing-page/hooks/useInView.ts +++ b/apps/portal/src/features/landing-page/hooks/useInView.ts @@ -7,8 +7,10 @@ const DEFAULT_OPTIONS: IntersectionObserverInit = {}; * Returns a ref and boolean indicating if element is in viewport. * Once the element becomes visible, it stays marked as "in view" (trigger once). */ -export function useInView(options: IntersectionObserverInit = DEFAULT_OPTIONS) { - const ref = useRef(null); +export function useInView( + options: IntersectionObserverInit = DEFAULT_OPTIONS +) { + const ref = useRef(null!); const [isInView, setIsInView] = useState(false); useEffect(() => { diff --git a/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts b/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts new file mode 100644 index 00000000..5b45a3b3 --- /dev/null +++ b/apps/portal/src/features/landing-page/hooks/useInfiniteCarousel.ts @@ -0,0 +1,146 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const AUTO_INTERVAL = 5000; + +function useResponsiveCardWidth() { + const [cardWidth, setCardWidth] = useState(520); + const rafRef = useRef(0); + + useEffect(() => { + const update = () => { + const vw = window.innerWidth; + if (vw < 640) setCardWidth(vw - 48); + else if (vw < 1024) setCardWidth(440); + else setCardWidth(520); + }; + const onResize = () => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(update); + }; + update(); + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("resize", onResize); + cancelAnimationFrame(rafRef.current); + }; + }, []); + + return cardWidth; +} + +export function useInfiniteCarousel({ items }: { items: T[] }) { + const total = items.length; + const autoRef = useRef | null>(null); + const touchXRef = useRef(0); + const cardWidth = useResponsiveCardWidth(); + const [trackIndex, setTrackIndex] = useState(1); + const [isTransitioning, setIsTransitioning] = useState(true); + + const extendedItems = useMemo(() => { + if (total === 0) return []; + return [items[total - 1]!, ...items, items[0]!]; + }, [items, total]); + + const activeIndex = (((trackIndex - 1) % total) + total) % total; + + const startAuto = useCallback(() => { + if (autoRef.current) clearInterval(autoRef.current); + autoRef.current = setInterval(() => { + setTrackIndex(prev => prev + 1); + setIsTransitioning(true); + }, AUTO_INTERVAL); + }, []); + + const stopAuto = useCallback(() => { + if (autoRef.current) { + clearInterval(autoRef.current); + autoRef.current = null; + } + }, []); + + useEffect(() => { + startAuto(); + return stopAuto; + }, [startAuto, stopAuto]); + + const handleTransitionEnd = useCallback(() => { + if (trackIndex >= total + 1) { + setIsTransitioning(false); + setTrackIndex(1); + } else if (trackIndex <= 0) { + setIsTransitioning(false); + setTrackIndex(total); + } + }, [trackIndex, total]); + + useEffect(() => { + if (isTransitioning) return; + const id = requestAnimationFrame(() => setIsTransitioning(true)); + return () => cancelAnimationFrame(id); + }, [isTransitioning]); + + const navigate = useCallback( + (updater: number | ((prev: number) => number)) => { + setTrackIndex(updater); + setIsTransitioning(true); + startAuto(); + }, + [startAuto] + ); + + const goTo = useCallback((i: number) => navigate(i + 1), [navigate]); + const goPrev = useCallback(() => navigate(p => p - 1), [navigate]); + const goNext = useCallback(() => navigate(p => p + 1), [navigate]); + + const reset = useCallback(() => { + setTrackIndex(1); + setIsTransitioning(false); + }, []); + + const onTouchStart = useCallback((e: React.TouchEvent) => { + const touch = e.touches[0]; + if (touch) touchXRef.current = touch.clientX; + }, []); + + const onTouchEnd = useCallback( + (e: React.TouchEvent) => { + const touch = e.changedTouches[0]; + if (!touch) return; + const diff = touchXRef.current - touch.clientX; + if (Math.abs(diff) > 50) { + if (diff > 0) goNext(); + else goPrev(); + } + }, + [goNext, goPrev] + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowLeft") goPrev(); + else if (e.key === "ArrowRight") goNext(); + }, + [goPrev, goNext] + ); + + return { + extendedItems, + total, + activeIndex, + trackIndex, + cardWidth, + isTransitioning, + handleTransitionEnd, + goTo, + goPrev, + goNext, + reset, + startAuto, + stopAuto, + onTouchStart, + onTouchEnd, + onKeyDown, + }; +} diff --git a/apps/portal/src/features/services/components/base/ServiceCTA.tsx b/apps/portal/src/features/services/components/base/ServiceCTA.tsx index c2558553..749b8abb 100644 --- a/apps/portal/src/features/services/components/base/ServiceCTA.tsx +++ b/apps/portal/src/features/services/components/base/ServiceCTA.tsx @@ -43,7 +43,7 @@ export function ServiceCTA({
{/* Background */}
-
+
{/* Decorative rings */}
@@ -117,7 +117,8 @@ export function ServicesOverviewContent({
@@ -182,7 +183,7 @@ export function ServicesOverviewContent({
diff --git a/apps/portal/src/shared/hooks/useCountUp.ts b/apps/portal/src/shared/hooks/useCountUp.ts index aeefb2f7..78db1f57 100644 --- a/apps/portal/src/shared/hooks/useCountUp.ts +++ b/apps/portal/src/shared/hooks/useCountUp.ts @@ -2,6 +2,9 @@ import { useState, useEffect, useRef } from "react"; +const reducedMotionQuery = + typeof window === "undefined" ? undefined : window.matchMedia("(prefers-reduced-motion: reduce)"); + interface UseCountUpOptions { /** Starting value (default: 0) */ start?: number; @@ -37,7 +40,7 @@ export function useCountUp({ } // Respect prefers-reduced-motion — show final value immediately - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + if (reducedMotionQuery?.matches) { setCount(end); return; } diff --git a/docs/plans/2026-03-04-carousel-and-contact-form-improvements-design.md b/docs/plans/2026-03-04-carousel-and-contact-form-improvements-design.md new file mode 100644 index 00000000..331550e2 --- /dev/null +++ b/docs/plans/2026-03-04-carousel-and-contact-form-improvements-design.md @@ -0,0 +1,322 @@ +# Carousel Badge Fix & Contact Form Improvements — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix carousel card badge positioning (inline with icon, not above), remove prices, and improve contact form design/layout across all 3 pages. + +**Architecture:** Pure UI changes across 5 existing files. No new components, no new abstractions. The shared `ContactForm` component gets tighter spacing and better styling; each page's surrounding layout is improved independently. + +**Tech Stack:** React 19, Tailwind CSS, Next.js 15, lucide-react icons + +--- + +### Task 1: Fix carousel card badge position and remove prices + +**Files:** + +- Modify: `apps/portal/src/features/landing-page/components/ServicesCarousel.tsx:16-41` + +**Step 1: Update `ServiceConversionCard` layout** + +Replace lines 22-38 (the card content inside `
`) with: + +```tsx + {/* Icon + Badge row */} +
+
{card.icon}
+ {card.badge && ( + + {card.badge} + + )} +
+

{card.problemHook}

+

{card.title}

+

+ {card.keyBenefit} +

+ + {card.ctaLabel} + + +``` + +Key changes: + +- Badge and icon now share a `flex items-center justify-between` row (icon left, badge right) +- Removed `priceFrom` rendering entirely (the `{card.priceFrom && ...}` block is gone) +- Badge no longer has `self-start` or `mb-3` since it's in the flex row + +**Step 2: Verify it builds** + +Run: `pnpm type-check` +Expected: No type errors (priceFrom still exists in the type, just not rendered) + +**Step 3: Visual check** + +Open localhost:3000 and verify: + +- Badge "1st month free" appears to the right of the phone icon on the Phone Plans card +- No prices shown on any card +- Cards without badges look normal (just icon, no empty space on the right) + +**Step 4: Commit** + +```bash +git add apps/portal/src/features/landing-page/components/ServicesCarousel.tsx +git commit -m "fix: move carousel badge inline with icon, remove prices" +``` + +--- + +### Task 2: Improve shared ContactForm styling + +**Files:** + +- Modify: `apps/portal/src/features/support/components/ContactForm.tsx` + +**Step 1: Tighten form spacing and polish styling** + +1. Line 63 — change outer container padding: + - From: `"bg-card rounded-2xl border border-border/60 p-6"` + - To: `"bg-card rounded-2xl border border-border/60 p-5 sm:p-6"` + +2. Line 70 — tighten form spacing: + - From: `className="space-y-5"` + - To: `className="space-y-4"` + +3. Lines 71, 102 — tighten grid gaps: + - From: `gap-5` (both grids) + - To: `gap-4` (both grids) + +4. Line 134 — improve textarea styling (add ring transition): + - From: `className="flex min-h-[120px] w-full rounded-lg border border-input bg-muted/20 px-4 py-3 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-colors resize-y text-sm"` + - To: `className="flex min-h-[100px] w-full rounded-lg border border-input bg-muted/20 px-3.5 py-2.5 text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50 transition-all resize-y text-sm"` + +5. Lines 155-157 — add clock icon to response time note: + - Add `Clock` to the lucide-react import (line 8) + - Replace the `

` with: + ```tsx +

+ + We typically respond within 24 hours. +
+ ``` + +**Step 2: Verify it builds** + +Run: `pnpm type-check` +Expected: No errors + +**Step 3: Visual check** + +Check form on all 3 pages (localhost:3000, /contact, /support). Verify tighter spacing, consistent styling. + +**Step 4: Commit** + +```bash +git add apps/portal/src/features/support/components/ContactForm.tsx +git commit -m "style: tighten contact form spacing and polish styling" +``` + +--- + +### Task 3: Improve landing page ContactSection layout + +**Files:** + +- Modify: `apps/portal/src/features/landing-page/components/ContactSection.tsx` + +**Step 1: Improve contact method visual hierarchy and tighten layout** + +Key changes to the left column (lines 27-52): + +- Give each contact method (form, chat, phone) a subtle background card with border +- Tighten `space-y-6` to `space-y-5` +- Reduce outer card padding from `p-6 sm:p-8` to `p-5 sm:p-7` +- Reduce grid gap from `gap-10 lg:gap-12` to `gap-8 lg:gap-10` + +Replace the entire left column `
` (lines 27-52) with: + +```tsx +
+
+ + By Online Form (Anytime) +
+ + +
+
+
+ + By Chat (Anytime) +
+

+ Click the “Chat Button” at the bottom right to reach our team. +

+
+
+
+ + By Phone (9:30-18:00 JST) +
+
+

+ Toll Free: 0120-660-470 +

+

+ Overseas: +81-3-3560-1006 +

+
+
+
+
+``` + +Also update the outer card and grid (lines 24-25): + +- Line 24: change `p-6 sm:p-8` to `p-5 sm:p-7` +- Line 25: change `gap-10 lg:gap-12` to `gap-8 lg:gap-10` + +And the right column (line 56): change `space-y-6` to `space-y-5` + +**Step 2: Verify it builds** + +Run: `pnpm type-check` + +**Step 3: Visual check** + +Check localhost:3000, scroll to bottom contact section. Verify: + +- Chat and Phone cards are side-by-side in subtle background cards +- Phone numbers are inline with labels (more compact) +- Overall section feels tighter and more organized + +**Step 4: Commit** + +```bash +git add apps/portal/src/features/landing-page/components/ContactSection.tsx +git commit -m "style: improve landing page contact section hierarchy and compactness" +``` + +--- + +### Task 4: Improve PublicContactView layout + +**Files:** + +- Modify: `apps/portal/src/features/support/views/PublicContactView.tsx` + +**Step 1: Tighten header and sidebar** + +1. Header section (lines 11-22) — reduce spacing: + - Change `mb-12 pt-8` to `mb-10 pt-8` + - Change `mb-4` (on heading) to `mb-3` + +2. Grid layout (line 25) — reduce gap: + - From: `gap-10 mb-16` + - To: `gap-8 mb-14` + +3. Sidebar cards (lines 32-122) — make more compact: + - Change `space-y-6` to `space-y-4` + - Change all sidebar card padding from `p-5` to `p-4` (Phone, Chat, Email, Hours, Office cards) + - Change icon containers from `h-11 w-11` to `h-10 w-10` + +**Step 2: Verify it builds** + +Run: `pnpm type-check` + +**Step 3: Visual check** + +Check localhost:3000/contact. Verify: + +- Sidebar cards are slightly more compact +- Overall page feels tighter +- Mobile stacking still works cleanly + +**Step 4: Commit** + +```bash +git add apps/portal/src/features/support/views/PublicContactView.tsx +git commit -m "style: tighten contact us page layout and sidebar spacing" +``` + +--- + +### Task 5: Improve PublicSupportView contact section + +**Files:** + +- Modify: `apps/portal/src/features/support/views/PublicSupportView.tsx:248-264` + +**Step 1: Tighten the "Still Need Help?" section** + +Replace lines 248-263 with: + +```tsx +{ + /* Contact Form Fallback */ +} +
+
+
+ +
+

+ Still Need Help? +

+

+ Send us a message and we'll get back to you within 24 hours. +

+
+
+ +
+
; +``` + +Key changes: + +- Icon container `w-12 h-12` → `w-11 h-11`, icon `h-6 w-6` → `h-5 w-5` +- Section `mb-6` → `mb-5` +- Icon margin `mb-3` → `mb-2.5` +- Description text size `text-base` → `text-sm` + +**Step 2: Verify it builds** + +Run: `pnpm type-check` + +**Step 3: Visual check** + +Check localhost:3000/support, scroll to bottom. Verify: + +- "Still Need Help?" section is slightly tighter +- Form uses updated shared styling + +**Step 4: Commit** + +```bash +git add apps/portal/src/features/support/views/PublicSupportView.tsx +git commit -m "style: tighten support page contact section spacing" +``` + +--- + +### Task 6: Final verification + +**Step 1: Run full lint and type check** + +```bash +pnpm type-check && pnpm lint +``` + +Expected: No errors + +**Step 2: Visual smoke test all 3 pages** + +- localhost:3000 — landing page carousel + bottom contact section +- localhost:3000/contact — contact us page +- localhost:3000/support — support page bottom form + +Check at mobile (375px) and desktop (1280px) widths. diff --git a/docs/plans/2026-03-04-trust-strip-redesign.md b/docs/plans/2026-03-04-trust-strip-redesign.md new file mode 100644 index 00000000..4ef8b3a9 --- /dev/null +++ b/docs/plans/2026-03-04-trust-strip-redesign.md @@ -0,0 +1,328 @@ +# 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 ( + + {stat.formatter ? stat.formatter(count) : count} + {stat.suffix} + + ); +} + +export function TrustStrip() { + const [ref, inView] = useInView(); + + return ( +
} + 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 */} +
+ +
+
+ {/* Animated stats */} + {stats.map((stat, i) => ( +
+
+ +
+
+ + {stat.label} +
+
+ ))} + + {/* Static stat — Foreign Cards */} +
+
+ +
+
+ Foreign Cards + Accepted +
+
+
+
+
+ ); +} +``` + +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; + + 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