refactor: update layout and components for improved consistency and accessibility

- Replaced the "About us.png" image with a new "about-us.png" for better naming consistency.
- Updated various sections across the landing page to use a full-bleed layout for a more modern design.
- Refactored ServiceCard component to conditionally render as a link or div based on the presence of an href prop, enhancing flexibility.
- Introduced a new CollapsibleSection component for better organization of content in service-related sections.
- Enhanced styling and structure in multiple components, including ContactSection, CTABanner, and TrustStrip, to improve visual hierarchy and user experience.
This commit is contained in:
barsa 2026-03-05 10:05:30 +09:00
parent 48eb8c8725
commit 7125f79baa
17 changed files with 296 additions and 307 deletions

View File

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

View File

@ -106,7 +106,7 @@ export function OnsiteSupportContent() {
return ( return (
<div className="relative"> <div className="relative">
{/* Hero Section */} {/* Hero Section */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 overflow-hidden bg-gradient-to-br from-slate-50 via-white to-sky-50/80 pt-10 pb-20"> <section className="full-bleed overflow-hidden bg-gradient-to-br from-slate-50 via-white to-sky-50/80 pt-10 pb-20">
{/* Dot grid pattern */} {/* Dot grid pattern */}
<div <div
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
@ -195,7 +195,7 @@ export function OnsiteSupportContent() {
</section> </section>
{/* How It Works Section */} {/* How It Works Section */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-16"> <section className="full-bleed bg-white py-16">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl sm:text-3xl font-bold text-foreground text-center mb-4"> <h2 className="text-2xl sm:text-3xl font-bold text-foreground text-center mb-4">
How It Works How It Works
@ -239,7 +239,7 @@ export function OnsiteSupportContent() {
</section> </section>
{/* Pricing Cards Section */} {/* Pricing Cards Section */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#f7f7f7] py-16"> <section className="full-bleed bg-[#f7f7f7] py-16">
{/* Subtle pattern overlay */} {/* Subtle pattern overlay */}
<div <div
className="absolute inset-0 pointer-events-none opacity-30" className="absolute inset-0 pointer-events-none opacity-30"
@ -335,7 +335,7 @@ export function OnsiteSupportContent() {
</section> </section>
{/* FAQ Section */} {/* FAQ Section */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-white py-16"> <section className="full-bleed bg-white py-16">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl sm:text-3xl font-bold text-foreground text-center mb-4"> <h2 className="text-2xl sm:text-3xl font-bold text-foreground text-center mb-4">
Frequently Asked Questions Frequently Asked Questions
@ -387,7 +387,7 @@ export function OnsiteSupportContent() {
</section> </section>
{/* CTA Section */} {/* CTA Section */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-[#f7f7f7] py-16"> <section className="full-bleed bg-[#f7f7f7] py-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2 text-sm text-primary font-medium mb-6"> <div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-2 text-sm text-primary font-medium mb-6">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />

View File

@ -35,8 +35,8 @@ export type ServiceCardVariant =
| "bento-lg"; | "bento-lg";
export interface ServiceCardProps { export interface ServiceCardProps {
/** Link destination */ /** Link destination (renders as div when omitted) */
href: string; href?: string;
/** /**
* Icon element to display. * Icon element to display.
* Pass a pre-styled JSX element: `icon={<Wifi className="h-6 w-6" />}` * Pass a pre-styled JSX element: `icon={<Wifi className="h-6 w-6" />}`
@ -157,6 +157,28 @@ function renderIcon(icon: ReactNode, className: string): ReactNode {
return icon; return icon;
} }
/**
* Shared wrapper that renders <Link> when href is provided, <div> otherwise
*/
function CardWrapper({
href,
className,
children,
}: {
href?: string | undefined;
className?: string;
children: ReactNode;
}) {
if (href) {
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
return <div className={className}>{children}</div>;
}
/** /**
* Default variant - Standard service card * Default variant - Standard service card
*/ */
@ -173,7 +195,7 @@ function DefaultCard({
const colors = accentColorStyles[accentColor]; const colors = accentColorStyles[accentColor];
return ( return (
<Link href={href} className={cn("group block", className)}> <CardWrapper href={href} className={cn("group block", className)}>
<div <div
className={cn( className={cn(
"relative h-full flex flex-col rounded-2xl border bg-card p-6", "relative h-full flex flex-col rounded-2xl border bg-card p-6",
@ -212,12 +234,14 @@ function DefaultCard({
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p> <p className="text-sm text-muted-foreground leading-relaxed flex-grow">{description}</p>
)} )}
{href && (
<div className="mt-4 flex items-center gap-1 text-sm font-medium text-primary"> <div className="mt-4 flex items-center gap-1 text-sm font-medium text-primary">
Learn more Learn more
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" /> <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</div> </div>
)}
</div> </div>
</Link> </CardWrapper>
); );
} }
@ -226,7 +250,7 @@ function DefaultCard({
*/ */
function FeaturedCard({ href, icon, title, description, highlight, className }: ServiceCardProps) { function FeaturedCard({ href, icon, title, description, highlight, className }: ServiceCardProps) {
return ( return (
<Link href={href} className={cn("group block h-full", className)}> <CardWrapper href={href} className={cn("group block h-full", className)}>
<div <div
className={cn( className={cn(
"h-full flex flex-col", "h-full flex flex-col",
@ -269,6 +293,7 @@ function FeaturedCard({ href, icon, title, description, highlight, className }:
)} )}
{/* Link indicator */} {/* Link indicator */}
{href && (
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 mt-4 pt-4 border-t", "flex items-center gap-1.5 mt-4 pt-4 border-t",
@ -280,8 +305,9 @@ function FeaturedCard({ href, icon, title, description, highlight, className }:
Learn more Learn more
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" /> <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</div> </div>
)}
</div> </div>
</Link> </CardWrapper>
); );
} }
@ -290,7 +316,7 @@ function FeaturedCard({ href, icon, title, description, highlight, className }:
*/ */
function MinimalCard({ href, icon, title, className }: ServiceCardProps) { function MinimalCard({ href, icon, title, className }: ServiceCardProps) {
return ( return (
<Link href={href} className={cn("group block", className)}> <CardWrapper href={href} className={cn("group block", className)}>
<div <div
className={cn( className={cn(
"flex flex-col items-center text-center", "flex flex-col items-center text-center",
@ -306,7 +332,7 @@ function MinimalCard({ href, icon, title, className }: ServiceCardProps) {
</div> </div>
<h3 className="text-sm font-semibold text-foreground font-display">{title}</h3> <h3 className="text-sm font-semibold text-foreground font-display">{title}</h3>
</div> </div>
</Link> </CardWrapper>
); );
} }
@ -317,7 +343,7 @@ function BentoSmallCard({ href, icon, title, accentColor = "blue", className }:
const colors = accentColorStyles[accentColor]; const colors = accentColorStyles[accentColor];
return ( return (
<Link <CardWrapper
href={href} href={href}
className={cn( className={cn(
"group rounded-xl bg-card/80 backdrop-blur-sm border border-border/50", "group rounded-xl bg-card/80 backdrop-blur-sm border border-border/50",
@ -333,7 +359,7 @@ function BentoSmallCard({ href, icon, title, accentColor = "blue", className }:
</div> </div>
<span className="font-semibold text-foreground">{title}</span> <span className="font-semibold text-foreground">{title}</span>
</div> </div>
</Link> </CardWrapper>
); );
} }
@ -351,7 +377,7 @@ function BentoMediumCard({
const colors = accentColorStyles[accentColor]; const colors = accentColorStyles[accentColor];
return ( return (
<Link <CardWrapper
href={href} href={href}
className={cn( className={cn(
"group rounded-xl bg-card border", "group rounded-xl bg-card border",
@ -377,7 +403,7 @@ function BentoMediumCard({
{description && ( {description && (
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p> <p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
)} )}
</Link> </CardWrapper>
); );
} }
@ -395,7 +421,7 @@ function BentoLargeCard({
const colors = accentColorStyles[accentColor]; const colors = accentColorStyles[accentColor];
return ( return (
<Link <CardWrapper
href={href} href={href}
className={cn( className={cn(
"group relative overflow-hidden rounded-2xl", "group relative overflow-hidden rounded-2xl",
@ -437,6 +463,7 @@ function BentoLargeCard({
<p className="text-muted-foreground leading-relaxed max-w-sm mb-6">{description}</p> <p className="text-muted-foreground leading-relaxed max-w-sm mb-6">{description}</p>
)} )}
{href && (
<span <span
className={cn( className={cn(
"inline-flex items-center gap-2 font-semibold", "inline-flex items-center gap-2 font-semibold",
@ -448,8 +475,9 @@ function BentoLargeCard({
Learn more Learn more
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</span> </span>
)}
</div> </div>
</Link> </CardWrapper>
); );
} }

View File

@ -3,10 +3,7 @@ import { Button } from "@/components/atoms/button";
export function CTABanner() { export function CTABanner() {
return ( return (
<section <section aria-label="Call to action" className="full-bleed bg-primary-soft">
aria-label="Call to action"
className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft"
>
<div className="mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 py-14 sm:py-16 text-center"> <div className="mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 py-14 sm:py-16 text-center">
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground"> <h2 className="text-2xl sm:text-3xl font-extrabold text-foreground">
Ready to Get Set Up? Ready to Get Set Up?

View File

@ -13,7 +13,7 @@ export function ContactSection() {
id="contact" id="contact"
ref={ref} ref={ref}
className={cn( 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", "full-bleed bg-surface-sunken/30 py-14 sm:py-16 transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)} )}
> >

View File

@ -16,7 +16,7 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
<section <section
ref={heroRef} ref={heroRef}
className={cn( 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", "full-bleed 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" heroInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)} )}
> >

View File

@ -299,7 +299,7 @@ export function ServicesCarousel() {
<section <section
ref={sectionRef} ref={sectionRef}
className={cn( 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", "full-bleed bg-surface-sunken/30 py-16 sm:py-20 transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)} )}
> >

View File

@ -73,7 +73,7 @@ export function TrustStrip() {
ref={ref} ref={ref}
aria-label="Company statistics" aria-label="Company statistics"
className={cn( 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", "full-bleed py-10 sm:py-12 overflow-hidden transition-all duration-700",
inView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" inView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)} )}
> >

View File

@ -19,7 +19,7 @@ export function WhyUsSection() {
<section <section
ref={ref} ref={ref}
className={cn( className={cn(
"relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background transition-all duration-700", "full-bleed bg-background transition-all duration-700",
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8" isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
)} )}
> >

View File

@ -4,7 +4,7 @@ export function PublicLandingLoadingView() {
return ( return (
<div className="space-y-0 pb-8 pt-0"> <div className="space-y-0 pb-8 pt-0">
{/* Hero Section Skeleton */} {/* Hero Section Skeleton */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken py-16 sm:py-20 lg:py-24"> <section className="full-bleed bg-surface-sunken py-16 sm:py-20 lg:py-24">
<div className="mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 text-center space-y-6"> <div className="mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 text-center space-y-6">
<div className="space-y-3 mx-auto max-w-lg"> <div className="space-y-3 mx-auto max-w-lg">
<Skeleton className="h-10 w-72 mx-auto rounded-md" /> <Skeleton className="h-10 w-72 mx-auto rounded-md" />
@ -22,7 +22,7 @@ export function PublicLandingLoadingView() {
</section> </section>
{/* TrustStrip Skeleton */} {/* TrustStrip Skeleton */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken/50 border-y border-border/40"> <section className="full-bleed bg-surface-sunken/50 border-y border-border/40">
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14 py-5"> <div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14 py-5">
<div className="grid grid-cols-2 gap-4 sm:flex sm:justify-between"> <div className="grid grid-cols-2 gap-4 sm:flex sm:justify-between">
{Array.from({ length: 4 }).map((_, idx) => ( {Array.from({ length: 4 }).map((_, idx) => (
@ -72,7 +72,7 @@ export function PublicLandingLoadingView() {
</section> </section>
{/* CTABanner Skeleton */} {/* CTABanner Skeleton */}
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft"> <section className="full-bleed bg-primary-soft">
<div className="mx-auto max-w-3xl px-6 sm:px-10 py-14 sm:py-16 text-center space-y-4"> <div className="mx-auto max-w-3xl px-6 sm:px-10 py-14 sm:py-16 text-center space-y-4">
<Skeleton className="h-8 w-64 mx-auto rounded-md" /> <Skeleton className="h-8 w-64 mx-auto rounded-md" />
<Skeleton className="h-4 w-80 mx-auto rounded-md" /> <Skeleton className="h-4 w-80 mx-auto rounded-md" />

View File

@ -1,4 +1,5 @@
import Image from "next/image"; import Image from "next/image";
import { ServiceCard } from "@/components/molecules";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import {
Wifi, Wifi,
@ -21,34 +22,39 @@ import {
/* ─── Data ─── */ /* ─── Data ─── */
const services: { title: string; description: string; icon: LucideIcon }[] = [ const services = [
{ {
title: "Internet Plans", title: "Internet Plans",
description: description:
"High-speed NTT fiber with English support. We handle the Japanese paperwork so you don't have to.", "High-speed NTT fiber with English support. We handle the Japanese paperwork so you don't have to.",
icon: Wifi, icon: <Wifi />,
accentColor: "blue" as const,
}, },
{ {
title: "Phone Plans", title: "Phone Plans",
description: description:
"SIM cards on Japan's best network. Foreign credit cards accepted, no hanko required.", "SIM cards on Japan's best network. Foreign credit cards accepted, no hanko required.",
icon: Smartphone, icon: <Smartphone />,
accentColor: "green" as const,
}, },
{ {
title: "Business Solutions", title: "Business Solutions",
description: description:
"Enterprise IT for international companies. Dedicated internet, office networks, and data centers.", "Enterprise IT for international companies. Dedicated internet, office networks, and data centers.",
icon: Building2, icon: <Building2 />,
accentColor: "purple" as const,
}, },
{ {
title: "VPN", title: "VPN",
description: "Stream your favorite shows from home. Pre-configured router for US/UK content.", description: "Stream your favorite shows from home. Pre-configured router for US/UK content.",
icon: Lock, icon: <Lock />,
accentColor: "orange" as const,
}, },
{ {
title: "Onsite Support", title: "Onsite Support",
description: "English-speaking technicians at your door for setup and troubleshooting.", description: "English-speaking technicians at your door for setup and troubleshooting.",
icon: Wrench, icon: <Wrench />,
accentColor: "cyan" as const,
}, },
]; ];
@ -111,10 +117,6 @@ const businessHours = [
{ team: "Onsite Tech Support Team", hours: "Mon \u2013 Sat 10:00AM \u2013 9:00PM" }, { team: "Onsite Tech Support Team", hours: "Mon \u2013 Sat 10:00AM \u2013 9:00PM" },
]; ];
/* ─── Full-width section wrapper ─── */
const SECTION_BASE = "relative left-1/2 right-1/2 w-screen -translate-x-1/2";
const HERO_GLOW_STYLE = { const HERO_GLOW_STYLE = {
background: "radial-gradient(circle, oklch(0.7 0.12 234.4 / 0.35), transparent 70%)", background: "radial-gradient(circle, oklch(0.7 0.12 234.4 / 0.35), transparent 70%)",
} as const; } as const;
@ -123,7 +125,7 @@ const HERO_GLOW_STYLE = {
function HeroSection() { function HeroSection() {
return ( return (
<section className={`${SECTION_BASE} overflow-hidden bg-surface-sunken`}> <section className="full-bleed overflow-hidden bg-surface-sunken">
<div <div
className="pointer-events-none absolute -top-1/3 right-0 h-[600px] w-[600px] opacity-30" className="pointer-events-none absolute -top-1/3 right-0 h-[600px] w-[600px] opacity-30"
style={HERO_GLOW_STYLE} style={HERO_GLOW_STYLE}
@ -152,7 +154,7 @@ function HeroSection() {
</div> </div>
<div className="relative mx-auto h-[380px] w-full max-w-md lg:h-[440px] lg:max-w-none"> <div className="relative mx-auto h-[380px] w-full max-w-md lg:h-[440px] lg:max-w-none">
<Image <Image
src="/assets/images/About us.png" src="/assets/images/about-us.png"
alt="Assist Solutions team in Tokyo" alt="Assist Solutions team in Tokyo"
fill fill
priority priority
@ -168,7 +170,7 @@ function HeroSection() {
function ServicesSection() { function ServicesSection() {
return ( return (
<section className={`${SECTION_BASE} bg-background py-16 sm:py-20`}> <section className="full-bleed bg-background py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8"> <div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10 max-w-xl"> <div className="cp-stagger-children mb-10 max-w-xl">
<h2 className="text-display-sm font-bold font-display text-foreground">What We Do</h2> <h2 className="text-display-sm font-bold font-display text-foreground">What We Do</h2>
@ -179,23 +181,16 @@ function ServicesSection() {
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{services.map(service => { {services.map(service => (
const Icon = service.icon; <ServiceCard
return (
<div
key={service.title} key={service.title}
className="group cp-card-hover-lift rounded-2xl border border-border/60 bg-card p-6 hover:border-primary/20" variant="bento-md"
> icon={service.icon}
<div className="mb-4 inline-flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground"> title={service.title}
<Icon className="h-5 w-5" /> description={service.description}
</div> accentColor={service.accentColor}
<h3 className="text-lg font-semibold text-foreground">{service.title}</h3> />
<p className="mt-2 text-sm leading-relaxed text-muted-foreground"> ))}
{service.description}
</p>
</div>
);
})}
</div> </div>
</div> </div>
</section> </section>
@ -204,7 +199,7 @@ function ServicesSection() {
function ValuesSection() { function ValuesSection() {
return ( return (
<section className={`${SECTION_BASE} bg-surface-sunken py-16 sm:py-20`}> <section className="full-bleed bg-surface-sunken py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8"> <div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10 text-center"> <div className="cp-stagger-children mb-10 text-center">
<h2 className="text-display-sm font-bold font-display text-foreground">Our Values</h2> <h2 className="text-display-sm font-bold font-display text-foreground">Our Values</h2>
@ -242,7 +237,7 @@ function ValuesSection() {
function CorporateSection() { function CorporateSection() {
return ( return (
<section className={`${SECTION_BASE} bg-background py-16 sm:py-20`}> <section className="full-bleed bg-background py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6 sm:px-8"> <div className="mx-auto max-w-6xl px-6 sm:px-8">
<div className="cp-stagger-children mb-10"> <div className="cp-stagger-children mb-10">
<h2 className="text-display-sm font-bold font-display text-foreground">Corporate Data</h2> <h2 className="text-display-sm font-bold font-display text-foreground">Corporate Data</h2>

View File

@ -0,0 +1,50 @@
"use client";
import { useState, type ElementType, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/utils";
interface CollapsibleSectionProps {
title: string;
icon: ElementType;
defaultOpen?: boolean;
children: ReactNode;
}
export function CollapsibleSection({
title,
icon: Icon,
defaultOpen = false,
children,
}: CollapsibleSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border border-border/60 rounded-xl overflow-hidden bg-card">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2.5">
<Icon className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-foreground">{title}</span>
</div>
<ChevronDown
className={cn(
"w-4 h-4 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-300",
isOpen ? "max-h-[2000px]" : "max-h-0"
)}
>
<div className="p-4 pt-0 border-t border-border/60">{children}</div>
</div>
</div>
);
}

View File

@ -24,6 +24,11 @@ export interface ServiceCTAProps {
className?: string | undefined; className?: string | undefined;
} }
const dotPatternStyle = {
backgroundImage: `radial-gradient(circle at center, color-mix(in oklch, var(--primary) 12%, transparent) 0.6px, transparent 0.6px)`,
backgroundSize: "20px 20px",
} as const;
/** /**
* ServiceCTA - Call-to-action section with decorative background. * ServiceCTA - Call-to-action section with decorative background.
*/ */
@ -42,13 +47,7 @@ export function ServiceCTA({
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-primary/8" /> <div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-primary/8" />
{/* Refined dot pattern */} {/* Refined dot pattern */}
<div <div className="absolute inset-0 pointer-events-none opacity-30" style={dotPatternStyle} />
className="absolute inset-0 pointer-events-none opacity-30"
style={{
backgroundImage: `radial-gradient(circle at center, color-mix(in oklch, var(--primary) 12%, transparent) 0.6px, transparent 0.6px)`,
backgroundSize: "20px 20px",
}}
/>
{/* Content */} {/* Content */}
<div className="relative"> <div className="relative">

View File

@ -1,12 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { cn } from "@/shared/utils/cn"; import { cn } from "@/shared/utils/cn";
export interface FAQItem { export interface FAQItem {
question: string; question: string;
answer: string; answer: ReactNode;
} }
export interface ServiceFAQProps { export interface ServiceFAQProps {
@ -24,7 +24,7 @@ function FAQItemComponent({
onToggle, onToggle,
}: { }: {
question: string; question: string;
answer: string; answer: ReactNode;
isOpen: boolean; isOpen: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
@ -53,7 +53,7 @@ function FAQItemComponent({
)} )}
> >
<div className="pb-4 pr-8"> <div className="pb-4 pr-8">
<p className="text-sm text-muted-foreground leading-relaxed">{answer}</p> <div className="text-sm text-muted-foreground leading-relaxed">{answer}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState, type ElementType, type ReactNode } from "react"; import { useMemo } from "react";
import { import {
Smartphone, Smartphone,
Check, Check,
@ -10,7 +10,6 @@ import {
Signal, Signal,
Sparkles, Sparkles,
CreditCard, CreditCard,
ChevronDown,
Info, Info,
CircleDollarSign, CircleDollarSign,
TriangleAlert, TriangleAlert,
@ -27,6 +26,8 @@ import { ServicesBackLink } from "@/features/services/components/base/ServicesBa
import { ServicesHero } from "@/features/services/components/base/ServicesHero"; import { ServicesHero } from "@/features/services/components/base/ServicesHero";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { CardPricing } from "@/features/services/components/base/CardPricing"; import { CardPricing } from "@/features/services/components/base/CardPricing";
import { CollapsibleSection } from "@/features/services/components/base/CollapsibleSection";
import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ";
import { DeviceCompatibility } from "./DeviceCompatibility"; import { DeviceCompatibility } from "./DeviceCompatibility";
import { import {
ServiceHighlights, ServiceHighlights,
@ -43,48 +44,110 @@ interface PlansByType {
VoiceOnly: SimCatalogProduct[]; VoiceOnly: SimCatalogProduct[];
} }
function CollapsibleSection({ const simFeatures: HighlightFeature[] = [
title, {
icon: Icon, icon: <CreditCard className="h-6 w-6" />,
defaultOpen = false, title: "Foreign Cards Accepted",
children, description: "Use your overseas credit card. No Japanese bank account needed",
}: { highlight: "Easy payment",
title: string; },
icon: ElementType; {
defaultOpen?: boolean; icon: <Signal className="h-6 w-6" />,
children: ReactNode; title: "Japan's Best Network",
}) { description: "NTT Docomo coverage reaches 99.9% of Japan's population",
const [isOpen, setIsOpen] = useState(defaultOpen); highlight: "Nationwide",
},
{
icon: <CircleDollarSign className="h-6 w-6" />,
title: "First Month Free",
description: "Try risk-free. Your first month's basic fee is waived",
highlight: "Great value",
},
{
icon: <Calendar className="h-6 w-6" />,
title: "Flexible Terms",
description: "No multi-year contracts. Stay as long as you need",
highlight: "No lock-in",
},
{
icon: <ArrowRightLeft className="h-6 w-6" />,
title: "Keep Your Number",
description: "Switching from another carrier? Bring your Japanese number with you",
highlight: "Easy transfer",
},
{
icon: <Smartphone className="h-6 w-6" />,
title: "Free Plan Changes",
description: "Switch data plans anytime for the next billing cycle",
highlight: "Flexibility",
},
];
return ( const SIM_TABS = [
<div className="border border-border/60 rounded-xl overflow-hidden bg-card"> {
<button key: "data-voice" as const,
type="button" icon: Phone,
onClick={() => setIsOpen(!isOpen)} label: "Data + Voice",
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/30 transition-colors" shortLabel: "All-in",
planTypeKey: "DataSmsVoice" as const,
},
{
key: "data-only" as const,
icon: Globe,
label: "Data Only",
shortLabel: "Data",
planTypeKey: "DataOnly" as const,
},
{
key: "voice-only" as const,
icon: Check,
label: "Voice Only",
shortLabel: "Voice",
planTypeKey: "VoiceOnly" as const,
},
];
const SIM_FAQ_ITEMS: FAQItem[] = [
{
question: "What is the service contract period?",
answer:
"The minimum service requirement period is activation month + 3 billing months. After this period, the service will switch to a monthly service and you will be able to cancel at the end of each month.",
},
{
question: "I've changed my phone and the SIM card is not working on the new device.",
answer:
"Whenever the SIM card is used with a new device, the APN profile would need to be installed on said device. Please refer to the APN Setup Guide in the Documents section to check how the profile can be installed.",
},
{
question: "Are international calling features available?",
answer: (
<>
Enter &quot;+&quot; or &quot;010&quot;, &quot;recipient&apos;s country code&quot;, and
&quot;recipient&apos;s phone number (regular phone number/mobile phone number)&quot; Make
a call.
<br />
<br />
If the recipient&apos;s phone number begins with a 0, enter it without the first 0 (except
in some countries and regions).
<br />
International calling rate is on the following Docomo&apos;s website:{" "}
<a
href="https://www.docomo.ne.jp/english/service/world/roaming/charges/kaigai/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
> >
<div className="flex items-center gap-2.5"> Docomo International Calling Rates
<Icon className="w-4 h-4 text-primary" /> </a>
<span className="text-sm font-medium text-foreground">{title}</span> </>
</div> ),
<ChevronDown },
className={cn( {
"w-4 h-4 text-muted-foreground transition-transform duration-200", question: "How do I cancel the service?",
isOpen && "rotate-180" answer:
)} "To cancel, please log into the SonixNet SIM Management Website and send us a cancellation request before the 25th to cancel your account at the end of the month. For example, cancellation requests will need to be sent in by May 25th, in order to cancel at the end of May.",
/> },
</button> ];
<div
className={cn(
"overflow-hidden transition-all duration-300",
isOpen ? "max-h-[2000px]" : "max-h-0"
)}
>
<div className="p-4 pt-0 border-t border-border/60">{children}</div>
</div>
</div>
);
}
function SimPlanCardCompact({ function SimPlanCardCompact({
plan, plan,
@ -187,45 +250,6 @@ export function SimPlansContent({
const simPlans: SimCatalogProduct[] = useMemo(() => plans ?? [], [plans]); const simPlans: SimCatalogProduct[] = useMemo(() => plans ?? [], [plans]);
const hasExistingSim = useMemo(() => simPlans.some(p => p.simHasFamilyDiscount), [simPlans]); const hasExistingSim = useMemo(() => simPlans.some(p => p.simHasFamilyDiscount), [simPlans]);
const simFeatures: HighlightFeature[] = [
{
icon: <CreditCard className="h-6 w-6" />,
title: "Foreign Cards Accepted",
description: "Use your overseas credit card. No Japanese bank account needed",
highlight: "Easy payment",
},
{
icon: <Signal className="h-6 w-6" />,
title: "Japan's Best Network",
description: "NTT Docomo coverage reaches 99.9% of Japan's population",
highlight: "Nationwide",
},
{
icon: <CircleDollarSign className="h-6 w-6" />,
title: "First Month Free",
description: "Try risk-free. Your first month's basic fee is waived",
highlight: "Great value",
},
{
icon: <Calendar className="h-6 w-6" />,
title: "Flexible Terms",
description: "No multi-year contracts. Stay as long as you need",
highlight: "No lock-in",
},
{
icon: <ArrowRightLeft className="h-6 w-6" />,
title: "Keep Your Number",
description: "Switching from another carrier? Bring your Japanese number with you",
highlight: "Easy transfer",
},
{
icon: <Smartphone className="h-6 w-6" />,
title: "Free Plan Changes",
description: "Switch data plans anytime for the next billing cycle",
highlight: "Flexibility",
},
];
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-5xl mx-auto px-4 pb-16 pt-6"> <div className="max-w-5xl mx-auto px-4 pb-16 pt-6">
@ -324,29 +348,7 @@ export function SimPlansContent({
{/* Tab Switcher */} {/* Tab Switcher */}
<div className="flex justify-center"> <div className="flex justify-center">
<div className="inline-flex rounded-lg bg-muted/60 p-0.5 border border-border/60"> <div className="inline-flex rounded-lg bg-muted/60 p-0.5 border border-border/60">
{[ {SIM_TABS.map(tab => (
{
key: "data-voice" as const,
icon: Phone,
label: "Data + Voice",
shortLabel: "All-in",
count: plansByType.DataSmsVoice.length,
},
{
key: "data-only" as const,
icon: Globe,
label: "Data Only",
shortLabel: "Data",
count: plansByType.DataOnly.length,
},
{
key: "voice-only" as const,
icon: Check,
label: "Voice Only",
shortLabel: "Voice",
count: plansByType.VoiceOnly.length,
},
].map(tab => (
<button <button
key={tab.key} key={tab.key}
type="button" type="button"
@ -362,7 +364,7 @@ export function SimPlansContent({
<span className="hidden sm:inline">{tab.label}</span> <span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.shortLabel}</span> <span className="sm:hidden">{tab.shortLabel}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/8 text-primary font-semibold"> <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/8 text-primary font-semibold">
{tab.count} {plansByType[tab.planTypeKey].length}
</span> </span>
</button> </button>
))} ))}
@ -631,54 +633,7 @@ export function SimPlansContent({
</div> </div>
{/* FAQ Section */} {/* FAQ Section */}
<div> <ServiceFAQ items={SIM_FAQ_ITEMS} />
<div className="text-center mb-5">
<p className="text-xs font-semibold text-primary uppercase tracking-wider mb-1.5">
Common Questions
</p>
<h2 className="text-xl sm:text-2xl font-bold leading-tight tracking-tight text-foreground">
Frequently Asked Questions
</h2>
</div>
<div className="space-y-2 max-w-3xl mx-auto">
<FaqItem
question="What is the service contract period?"
answer="The minimum service requirement period is activation month + 3 billing months. After this period, the service will switch to a monthly service and you will be able to cancel at the end of each month."
/>
<FaqItem
question="I've changed my phone and the SIM card is not working on the new device."
answer="Whenever the SIM card is used with a new device, the APN profile would need to be installed on said device. Please refer to the APN Setup Guide in the Documents section to check how the profile can be installed."
/>
<FaqItem
question="Are international calling features available?"
answer={
<>
Enter &quot;+&quot; or &quot;010&quot;, &quot;recipient&apos;s country code&quot;,
and &quot;recipient&apos;s phone number (regular phone number/mobile phone
number)&quot; Make a call.
<br />
<br />
If the recipient&apos;s phone number begins with a 0, enter it without the first 0
(except in some countries and regions).
<br />
International calling rate is on the following Docomo&apos;s website:{" "}
<a
href="https://www.docomo.ne.jp/english/service/world/roaming/charges/kaigai/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Docomo International Calling Rates
</a>
</>
}
/>
<FaqItem
question="How do I cancel the service?"
answer="To cancel, please log into the SonixNet SIM Management Website and send us a cancellation request before the 25th to cancel your account at the end of the month. For example, cancellation requests will need to be sent in by May 25th, in order to cancel at the end of May."
/>
</div>
</div>
{/* Footer */} {/* Footer */}
<div className="text-center text-xs text-muted-foreground"> <div className="text-center text-xs text-muted-foreground">
@ -693,31 +648,4 @@ export function SimPlansContent({
); );
} }
function FaqItem({ question, answer }: { question: string; answer: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="border border-border/60 rounded-xl overflow-hidden bg-card">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-start justify-between gap-3 p-4 text-left group"
>
<span className="text-sm font-medium text-foreground group-hover:text-primary transition-colors">
{question}
</span>
<ChevronDown
className={cn(
"w-4 h-4 text-muted-foreground flex-shrink-0 mt-0.5 transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
{isOpen && (
<div className="px-4 pb-4 text-xs text-muted-foreground leading-relaxed">{answer}</div>
)}
</div>
);
}
export default SimPlansContent; export default SimPlansContent;

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { ShieldCheck, Zap, CreditCard, Play, Globe, Package, ArrowLeft } from "lucide-react"; import { ShieldCheck, Zap, CreditCard, Play, Globe, Package, ArrowLeft } from "lucide-react";
import { ServicesHero } from "@/features/services/components/base/ServicesHero";
import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
@ -135,35 +136,18 @@ export function VpnPlansContent({
<ServicesBackLink href={servicesBasePath} label="Back to Services" /> <ServicesBackLink href={servicesBasePath} label="Back to Services" />
{/* Hero */} {/* Hero */}
<div className="text-center py-4"> <ServicesHero
<div title="Stream Content from Abroad"
className="animate-in fade-in slide-in-from-bottom-4 duration-500" description="Access US and UK streaming services using a pre-configured VPN router. No technical setup required."
style={{ animationDelay: "0ms" }} eyebrow={
> <span className="inline-flex items-center gap-1.5 rounded-full bg-violet-500/10 border border-violet-500/15 px-3 py-1 text-xs text-violet-600 dark:text-violet-400 font-medium">
<span className="inline-flex items-center gap-1.5 rounded-full bg-violet-500/10 border border-violet-500/15 px-3 py-1 text-xs text-violet-600 dark:text-violet-400 font-medium mb-3">
<ShieldCheck className="h-3.5 w-3.5" /> <ShieldCheck className="h-3.5 w-3.5" />
VPN Router Service VPN Router Service
</span> </span>
</div> }
<h1
className="text-2xl md:text-3xl font-bold leading-tight tracking-tight text-foreground animate-in fade-in slide-in-from-bottom-6 duration-700"
style={{ animationDelay: "100ms" }}
> >
Stream Content from Abroad
</h1>
<p
className="text-sm text-muted-foreground mt-2 max-w-lg mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "200ms" }}
>
Access US and UK streaming services using a pre-configured VPN router. No technical setup
required.
</p>
{variant === "public" && ( {variant === "public" && (
<div <div className="flex justify-center">
className="inline-flex mt-4 animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="bg-success-soft border border-success/20 rounded-lg px-3 py-2"> <div className="bg-success-soft border border-success/20 rounded-lg px-3 py-2">
<div className="flex items-center gap-1.5 justify-center"> <div className="flex items-center gap-1.5 justify-center">
<Zap className="h-3.5 w-3.5 text-success flex-shrink-0" /> <Zap className="h-3.5 w-3.5 text-success flex-shrink-0" />
@ -178,7 +162,7 @@ export function VpnPlansContent({
</div> </div>
</div> </div>
)} )}
</div> </ServicesHero>
{/* Highlights */} {/* Highlights */}
<ServiceHighlights features={VPN_FEATURES} /> <ServiceHighlights features={VPN_FEATURES} />

View File

@ -142,6 +142,14 @@
font-family are explicit Tailwind classes at the call site so they font-family are explicit Tailwind classes at the call site so they
can be overridden without cascade conflicts. */ can be overridden without cascade conflicts. */
@utility full-bleed {
position: relative;
left: 50%;
right: 50%;
width: 100vw;
transform: translateX(-50%);
}
@utility text-display-xl { @utility text-display-xl {
font-size: var(--cp-text-display-xl); font-size: var(--cp-text-display-xl);
letter-spacing: var(--cp-tracking-tight); letter-spacing: var(--cp-tracking-tight);