refactor: update landing page components and styles
- Removed obsolete components including ContactSection, PopularServicesSection, RemoteSupportSection, SolutionsCarousel, and TrustSection to streamline the landing page. - Introduced new components such as TrustStrip and ServicesGrid for improved layout and functionality. - Enhanced global CSS with new line-height tokens and updated styles for better typography. - Updated PublicLandingLoadingView for consistent loading states across the landing page. - Added new landing services data structure to support the ServicesGrid component.
This commit is contained in:
parent
6b13d74d06
commit
0523bf80a8
@ -316,6 +316,9 @@
|
||||
/* Glass tokens */
|
||||
--color-glass-bg: var(--glass-bg);
|
||||
--color-glass-border: var(--glass-border);
|
||||
|
||||
/* Line-height tokens */
|
||||
--leading-display: 1.1;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Phone } from "lucide-react";
|
||||
|
||||
export function CTABanner() {
|
||||
return (
|
||||
<section 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 py-14 sm:py-16 text-center">
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground">
|
||||
Ready to Get Connected?
|
||||
</h2>
|
||||
<p className="mt-2 text-base text-muted-foreground">
|
||||
No Japanese required. Our English-speaking team is here to help.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-lg font-semibold text-primary">
|
||||
<Phone className="h-5 w-5" />
|
||||
<a href="tel:03-5812-1050" className="hover:underline">
|
||||
03-5812-1050
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full px-8 py-3 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-transform hover:-translate-y-0.5"
|
||||
>
|
||||
Browse Services
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full px-8 py-3 text-base font-semibold border border-border bg-card text-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,302 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Mail,
|
||||
MessageSquare,
|
||||
PhoneCall,
|
||||
Train,
|
||||
MapPin,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Spinner } from "@/components/atoms";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { CONTACT_SUBJECTS, type FormErrors } from "@/features/landing-page/data";
|
||||
import { useInView, useContactForm } from "@/features/landing-page/hooks";
|
||||
|
||||
type ContactFormReturn = ReturnType<typeof useContactForm>;
|
||||
|
||||
interface ContactSectionProps {
|
||||
formData: ContactFormReturn["formData"];
|
||||
formErrors: ContactFormReturn["formErrors"];
|
||||
formTouched: ContactFormReturn["formTouched"];
|
||||
isSubmitting: ContactFormReturn["isSubmitting"];
|
||||
submitStatus: ContactFormReturn["submitStatus"];
|
||||
handleInputChange: ContactFormReturn["handleInputChange"];
|
||||
handleInputBlur: ContactFormReturn["handleInputBlur"];
|
||||
handleSubmit: ContactFormReturn["handleSubmit"];
|
||||
}
|
||||
|
||||
function getInputClassName(
|
||||
fieldName: keyof FormErrors,
|
||||
formTouched: ContactSectionProps["formTouched"],
|
||||
formErrors: ContactSectionProps["formErrors"]
|
||||
) {
|
||||
const baseClass =
|
||||
"w-full rounded-md border px-4 py-3 text-sm focus-visible:outline-none focus-visible:ring-2 bg-card transition-colors";
|
||||
const hasError = formTouched[fieldName] && formErrors[fieldName];
|
||||
return `${baseClass} ${
|
||||
hasError
|
||||
? "border-danger focus-visible:ring-danger/60"
|
||||
: "border-border focus-visible:ring-primary/60"
|
||||
}`;
|
||||
}
|
||||
|
||||
export function ContactSection({
|
||||
formData,
|
||||
formErrors,
|
||||
formTouched,
|
||||
isSubmitting,
|
||||
submitStatus,
|
||||
handleInputChange,
|
||||
handleInputBlur,
|
||||
handleSubmit,
|
||||
}: ContactSectionProps) {
|
||||
const [contactRef, contactInView] = useInView();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={contactRef as React.RefObject<HTMLElement>}
|
||||
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft py-14 sm:py-16 transition-all duration-700 ${
|
||||
contactInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 space-y-6">
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">CONTACT US</h2>
|
||||
<div className="rounded-3xl bg-card border border-primary/20 shadow-sm p-6 sm:p-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 lg:gap-12">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-2 text-primary font-bold text-lg">
|
||||
<Mail className="h-5 w-5" />
|
||||
<span>By Online Form (Anytime)</span>
|
||||
</div>
|
||||
|
||||
{submitStatus === "success" && (
|
||||
<AlertBanner variant="success" title="Message sent successfully!">
|
||||
Thank you for contacting us. We'll get back to you within 24 hours.
|
||||
</AlertBanner>
|
||||
)}
|
||||
{submitStatus === "error" && (
|
||||
<AlertBanner variant="error" title="Failed to send message">
|
||||
Please try again or contact us directly by phone.
|
||||
</AlertBanner>
|
||||
)}
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit} noValidate>
|
||||
{/* Subject Dropdown */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="contact-subject" className="sr-only">
|
||||
Subject
|
||||
</label>
|
||||
<select
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
aria-required="true"
|
||||
aria-invalid={formTouched.subject && !!formErrors.subject}
|
||||
aria-describedby={formErrors.subject ? "subject-error" : undefined}
|
||||
className={getInputClassName("subject", formTouched, formErrors)}
|
||||
>
|
||||
{CONTACT_SUBJECTS.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{formTouched.subject && formErrors.subject && (
|
||||
<p id="subject-error" className="text-sm text-danger flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{formErrors.subject}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name Input */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="contact-name" className="sr-only">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Name*"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
aria-required="true"
|
||||
aria-invalid={formTouched.name && !!formErrors.name}
|
||||
aria-describedby={formErrors.name ? "name-error" : undefined}
|
||||
className={getInputClassName("name", formTouched, formErrors)}
|
||||
/>
|
||||
{formTouched.name && formErrors.name && (
|
||||
<p id="name-error" className="text-sm text-danger flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{formErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Input */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="contact-email" className="sr-only">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Email*"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
aria-required="true"
|
||||
aria-invalid={formTouched.email && !!formErrors.email}
|
||||
aria-describedby={formErrors.email ? "email-error" : undefined}
|
||||
className={getInputClassName("email", formTouched, formErrors)}
|
||||
/>
|
||||
{formTouched.email && formErrors.email && (
|
||||
<p id="email-error" className="text-sm text-danger flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{formErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Input (optional) */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="contact-phone" className="sr-only">
|
||||
Phone (optional)
|
||||
</label>
|
||||
<input
|
||||
id="contact-phone"
|
||||
type="tel"
|
||||
name="phone"
|
||||
placeholder="Phone (optional)"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
className="w-full rounded-md border border-border px-4 py-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message Textarea */}
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="contact-message" className="sr-only">
|
||||
Inquiry
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
placeholder="Inquiry*"
|
||||
rows={4}
|
||||
value={formData.message}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
aria-required="true"
|
||||
aria-invalid={formTouched.message && !!formErrors.message}
|
||||
aria-describedby={formErrors.message ? "message-error" : undefined}
|
||||
className={`${getInputClassName("message", formTouched, formErrors)} resize-none`}
|
||||
/>
|
||||
{formTouched.message && formErrors.message && (
|
||||
<p id="message-error" className="text-sm text-danger flex items-center gap-1">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{formErrors.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
Sending...
|
||||
</>
|
||||
) : submitStatus === "success" ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Sent!
|
||||
</>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="inline-flex items-center gap-2 text-primary font-semibold">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span>By Chat (Anytime)</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground">
|
||||
Click the bottom right “Chat Button” to reach our team anytime.
|
||||
</p>
|
||||
<div className="inline-flex items-center gap-2 text-primary font-semibold">
|
||||
<PhoneCall className="h-5 w-5" />
|
||||
<span>By Phone (9:30-18:00 JST)</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground">
|
||||
<p className="font-semibold">Toll Free within Japan</p>
|
||||
<p className="text-lg font-bold text-primary">0120-660-470</p>
|
||||
<p className="font-semibold mt-1">From Overseas (may incur calling rates)</p>
|
||||
<p className="text-lg font-bold text-primary">+81-3-3560-1006</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="w-full rounded-2xl overflow-hidden shadow-md border border-border/60 bg-card aspect-[4/3]">
|
||||
<iframe
|
||||
title="Assist Solutions Corp Map"
|
||||
src="https://www.google.com/maps?q=Assist+Solutions+Corp,+3-8-2+Higashi+Azabu,+Minato-ku,+Tokyo&output=embed"
|
||||
className="w-full h-full"
|
||||
loading="lazy"
|
||||
allowFullScreen
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-2xl bg-card shadow-sm border border-border/60 p-5 space-y-2">
|
||||
<div className="inline-flex items-center gap-2 text-primary font-semibold">
|
||||
<Train className="h-5 w-5" />
|
||||
<span>Access</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground">
|
||||
Subway Oedo Line / Nanboku Line
|
||||
<br />
|
||||
Short distance walk from exit 6 of Azabu-Juban Station
|
||||
<br />
|
||||
(1F of our building is Domino's Pizza)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card shadow-sm border border-border/60 p-5 space-y-2">
|
||||
<div className="inline-flex items-center gap-2 text-primary font-semibold">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Address</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground">
|
||||
3F Azabu Maruka Bldg.,
|
||||
<br />
|
||||
3-8-2 Higashi Azabu, Minato-ku,
|
||||
<br />
|
||||
Tokyo 106-0044
|
||||
<br />
|
||||
Tel: 03-3560-1006 Fax: 03-3560-1007
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import {
|
||||
personalServices,
|
||||
businessServices,
|
||||
mobileQuickServices,
|
||||
type ServiceCategory,
|
||||
} from "@/features/landing-page/data";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
interface HeroSectionProps {
|
||||
@ -17,12 +10,11 @@ interface HeroSectionProps {
|
||||
|
||||
export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
||||
const [heroRef, heroInView] = useInView();
|
||||
const [activeCategory, setActiveCategory] = useState<ServiceCategory>("personal");
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={heroRef as React.RefObject<HTMLElement>}
|
||||
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 py-12 sm:py-16 overflow-hidden transition-all duration-700 ${
|
||||
className={`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"
|
||||
}`}
|
||||
>
|
||||
@ -46,141 +38,34 @@ export function HeroSection({ heroCTARef }: HeroSectionProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl grid grid-cols-1 lg:grid-cols-[1.05fr_minmax(0,0.95fr)] items-center gap-10 lg:gap-16 px-6 sm:px-10 lg:px-14">
|
||||
<div className="space-y-6 text-left max-w-2xl">
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground">
|
||||
<span className="block whitespace-nowrap">A One Stop Solution</span>
|
||||
<span className="block text-primary mt-2 whitespace-nowrap">for Your IT Needs</span>
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-muted-foreground leading-relaxed font-semibold">
|
||||
No Japanese required. Get reliable internet, mobile, and VPN services with full English
|
||||
support. Serving expats and international businesses for over 20 years.
|
||||
</p>
|
||||
<div
|
||||
ref={heroCTARef}
|
||||
className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4 pt-2"
|
||||
<div className="relative mx-auto max-w-3xl px-6 sm:px-10 lg:px-14 text-center">
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold leading-tight text-foreground">
|
||||
<span className="block">English IT Support</span>
|
||||
<span className="block text-primary mt-2">for Expats in Japan</span>
|
||||
</h1>
|
||||
<p className="mt-6 text-base sm:text-lg text-muted-foreground leading-relaxed font-semibold max-w-2xl mx-auto">
|
||||
No Japanese required. Get reliable internet, mobile, and VPN services with full English
|
||||
support. Serving expats and international businesses for over 20 years.
|
||||
</p>
|
||||
<div
|
||||
ref={heroCTARef}
|
||||
className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4"
|
||||
>
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full px-6 sm:px-8 py-3 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-transform hover:-translate-y-0.5"
|
||||
>
|
||||
<Link
|
||||
href="/services"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full px-6 sm:px-8 py-3 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-transform hover:-translate-y-0.5"
|
||||
>
|
||||
Browse Services
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full px-6 sm:px-8 py-3 text-base font-semibold border border-border bg-card text-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
Need Assistance?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Quick Services - visible on mobile only */}
|
||||
<div className="lg:hidden w-full">
|
||||
<div
|
||||
className="flex gap-3 overflow-x-auto pb-4 snap-x snap-mandatory"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
Browse Services
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full px-6 sm:px-8 py-3 text-base font-semibold border border-border bg-card text-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
{mobileQuickServices.map(service => (
|
||||
<Link
|
||||
key={service.title}
|
||||
href={service.href}
|
||||
className="flex-shrink-0 w-[140px] snap-start group"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 p-4 rounded-xl border border-border/70 bg-card shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-primary/30 flex items-center justify-center group-hover:border-primary/60 transition-colors">
|
||||
<div className="[&>svg]:h-6 [&>svg]:w-6">{service.icon}</div>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-foreground text-center">
|
||||
{service.title}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<Link href="/services" className="flex-shrink-0 w-[140px] snap-start group">
|
||||
<div className="flex flex-col items-center justify-center gap-2 p-4 rounded-xl border border-dashed border-primary/40 bg-primary/5 h-full min-h-[120px] hover:bg-primary/10 transition-colors">
|
||||
<ArrowRight className="h-6 w-6 text-primary" />
|
||||
<span className="text-xs font-semibold text-primary text-center">
|
||||
See All Services
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Services Panel - hidden on mobile */}
|
||||
<div className="hidden lg:block relative w-full">
|
||||
<div className="rounded-2xl border border-border/70 bg-card shadow-lg p-6">
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex mb-6 bg-muted rounded-full p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategory("personal")}
|
||||
className={`flex-1 py-2.5 px-4 text-sm font-semibold rounded-full transition-all ${
|
||||
activeCategory === "personal"
|
||||
? "bg-foreground text-background shadow-md"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Personal Services
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategory("business")}
|
||||
className={`flex-1 py-2.5 px-4 text-sm font-semibold rounded-full transition-all ${
|
||||
activeCategory === "business"
|
||||
? "bg-foreground text-background shadow-md"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Business Services
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Services Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 h-[320px] content-start overflow-hidden">
|
||||
{(activeCategory === "personal" ? personalServices : businessServices).map(
|
||||
service => (
|
||||
<Link
|
||||
key={service.title}
|
||||
href={service.href}
|
||||
className={`group flex flex-col items-center justify-center gap-2 p-3 rounded-xl hover:bg-muted/50 transition-colors ${
|
||||
activeCategory === "personal" ? "h-[152px]" : "h-[100px]"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-full border-2 border-primary/30 flex items-center justify-center group-hover:border-primary/60 transition-colors flex-shrink-0 ${
|
||||
activeCategory === "personal" ? "w-20 h-20" : "w-14 h-14"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
activeCategory === "personal"
|
||||
? "[&>svg]:h-10 [&>svg]:w-10"
|
||||
: "[&>svg]:h-7 [&>svg]:w-7"
|
||||
}
|
||||
>
|
||||
{service.icon}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`font-semibold text-foreground text-center leading-tight ${
|
||||
activeCategory === "personal" ? "text-sm" : "text-xs"
|
||||
}`}
|
||||
>
|
||||
{service.title}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
Need Assistance?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient fade to next section */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-background pointer-events-none" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Check, Wifi, Smartphone } from "lucide-react";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
export function PopularServicesSection() {
|
||||
const [popularRef, popularInView] = useInView();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={popularRef as React.RefObject<HTMLElement>}
|
||||
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background py-14 sm:py-16 transition-all duration-700 ${
|
||||
popularInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-10 sm:mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground mb-4">
|
||||
Most Popular Services
|
||||
</h2>
|
||||
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
Get connected with ease. No Japanese required, no complicated paperwork, and full
|
||||
English support every step of the way.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Two-column card layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
||||
{/* Internet Plans Card */}
|
||||
<div className="relative rounded-2xl border border-info-border bg-card shadow-lg p-6 sm:p-8 flex flex-col">
|
||||
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
|
||||
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-bold text-primary-foreground uppercase tracking-wide">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-info-soft flex items-center justify-center mb-5">
|
||||
<Wifi className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-foreground mb-3">Internet Plans</h3>
|
||||
|
||||
<p className="text-muted-foreground mb-5 leading-relaxed">
|
||||
High-speed fiber internet on Japan's reliable NTT network. We handle all the
|
||||
Japanese paperwork and coordinate installation in English.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3 mb-6 flex-grow">
|
||||
{[
|
||||
"Speeds up to 10 Gbps available",
|
||||
"English installation coordination",
|
||||
"No Japanese contracts to sign",
|
||||
"Foreign credit cards accepted",
|
||||
].map(feature => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
|
||||
<Check className="h-3 w-3 text-primary" />
|
||||
</span>
|
||||
<span className="text-sm text-foreground">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href="/services/internet"
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
View Plans
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Phone Plans Card */}
|
||||
<div className="relative rounded-2xl border border-info-border bg-card shadow-lg p-6 sm:p-8 flex flex-col">
|
||||
<div className="absolute top-4 right-4 sm:top-6 sm:right-6">
|
||||
<span className="inline-flex items-center rounded-full bg-primary px-3 py-1 text-xs font-bold text-primary-foreground uppercase tracking-wide">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-info-soft flex items-center justify-center mb-5">
|
||||
<Smartphone className="h-8 w-8 sm:h-10 sm:w-10 text-primary" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl sm:text-2xl font-bold text-foreground mb-3">Phone Plans</h3>
|
||||
|
||||
<p className="text-muted-foreground mb-5 leading-relaxed">
|
||||
Mobile SIM cards with voice and data on Japan's top network. Sign up online, no
|
||||
hanko needed, and get your SIM delivered fast.
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3 mb-6 flex-grow">
|
||||
{[
|
||||
"Data-only and voice + data options",
|
||||
"Keep your number with MNP transfer",
|
||||
"eSIM available for instant activation",
|
||||
"Flexible plans with no long-term contracts",
|
||||
].map(feature => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center mt-0.5">
|
||||
<Check className="h-3 w-3 text-primary" />
|
||||
</span>
|
||||
<span className="text-sm text-foreground">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href="/services/sim"
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3.5 text-base font-semibold text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
View Plans
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,192 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { supportDownloads } from "@/features/landing-page/data";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
export function RemoteSupportSection() {
|
||||
const [supportRef, supportInView] = useInView();
|
||||
const [remoteSupportTab, setRemoteSupportTab] = useState(0);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={supportRef as React.RefObject<HTMLElement>}
|
||||
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background py-12 sm:py-16 transition-all duration-700 overflow-hidden ${
|
||||
supportInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
{/* Gradient fade to next section */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-primary-soft pointer-events-none" />
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-6 sm:px-10 lg:px-14">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="relative">
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-primary tracking-tight">
|
||||
Remote Support
|
||||
</h2>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mt-2 max-w-md">
|
||||
Download one of our secure tools and let our technicians help you remotely
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Stacked Cards Layout */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{supportDownloads.map(item => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="group relative bg-card rounded-2xl border border-border/60 shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-sky-400 to-cyan-500" />
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-16 h-16 rounded-xl flex items-center justify-center bg-gradient-to-br from-sky-50 to-cyan-100">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.title}
|
||||
width={48}
|
||||
height={48}
|
||||
className="object-contain w-10 h-10"
|
||||
/>
|
||||
</div>
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-sky-400 animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-foreground mb-1">{item.title}</h3>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide mb-2 text-info">
|
||||
{item.useCase.replace("Best for: ", "")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 flex items-center justify-center gap-2 w-full rounded-xl py-3.5 text-sm font-semibold text-white transition-all duration-200 active:scale-[0.98] bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Download {item.title.split(" ")[0]}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Tabbed Layout */}
|
||||
<div className="hidden md:block">
|
||||
<div className="bg-card rounded-3xl border border-border/60 shadow-lg overflow-hidden">
|
||||
<div className="flex border-b border-border/40">
|
||||
{supportDownloads.map((item, index) => (
|
||||
<button
|
||||
key={item.title}
|
||||
type="button"
|
||||
onClick={() => setRemoteSupportTab(index)}
|
||||
className={`flex-1 flex items-center justify-center gap-3 py-4 px-6 text-base font-medium transition-all duration-200 relative ${
|
||||
remoteSupportTab === index
|
||||
? "text-primary bg-primary/5"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
aria-pressed={remoteSupportTab === index}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
|
||||
remoteSupportTab === index ? "bg-info-soft" : "bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={item.image}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span>{item.title}</span>
|
||||
{remoteSupportTab === index && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-to-r from-sky-400 to-cyan-500" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const currentDownload = supportDownloads[remoteSupportTab];
|
||||
if (!currentDownload) return null;
|
||||
return (
|
||||
<div className="p-8 lg:p-12">
|
||||
<div className="flex items-start gap-8 lg:gap-12">
|
||||
<div className="h-32 w-32 lg:h-40 lg:w-40 rounded-2xl flex items-center justify-center flex-shrink-0 transition-colors bg-gradient-to-br from-sky-50 to-cyan-100">
|
||||
<Image
|
||||
src={currentDownload.image}
|
||||
alt={currentDownload.title}
|
||||
width={96}
|
||||
height={96}
|
||||
className="object-contain w-20 h-20 lg:w-24 lg:h-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold uppercase tracking-wide mb-2 text-info">
|
||||
{currentDownload.useCase}
|
||||
</p>
|
||||
<h3 className="text-2xl lg:text-3xl font-bold text-foreground mb-4">
|
||||
{currentDownload.title}
|
||||
</h3>
|
||||
<p className="text-base lg:text-lg text-muted-foreground leading-relaxed mb-6">
|
||||
{currentDownload.description}
|
||||
</p>
|
||||
<Link
|
||||
href={currentDownload.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full px-8 py-3.5 text-base font-semibold text-white transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg bg-gradient-to-r from-sky-500 to-cyan-600 hover:from-sky-600 hover:to-cyan-700 shadow-sky-200"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Download Now
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { ServiceCard } from "@/components/molecules";
|
||||
import { landingServices } from "@/features/landing-page/data";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
export function ServicesGrid() {
|
||||
const [ref, isInView] = useInView();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={ref as React.RefObject<HTMLElement>}
|
||||
className={`py-14 sm:py-16 transition-all duration-700 ${
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-foreground">Our Services</h2>
|
||||
<p className="mt-2 text-base text-muted-foreground">
|
||||
Everything you need to stay connected in Japan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{landingServices.map(service => (
|
||||
<ServiceCard
|
||||
key={service.title}
|
||||
variant="default"
|
||||
href={service.href}
|
||||
icon={service.icon}
|
||||
title={service.title}
|
||||
description={service.description}
|
||||
accentColor={service.accentColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,281 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { services } from "@/features/landing-page/data";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
export function SolutionsCarousel() {
|
||||
const [solutionsRef, solutionsInView] = useInView();
|
||||
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const itemWidthRef = useRef(0);
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const isScrollingRef = useRef(false);
|
||||
const autoScrollTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const touchStartXRef = useRef(0);
|
||||
const touchEndXRef = useRef(0);
|
||||
const [pageCount, setPageCount] = useState(services.length);
|
||||
|
||||
const computeItemWidth = useCallback(() => {
|
||||
const container = carouselRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const card = container.querySelector<HTMLElement>("[data-service-card]");
|
||||
if (!card) return;
|
||||
|
||||
const gap =
|
||||
Number.parseFloat(getComputedStyle(container).columnGap || "0") ||
|
||||
Number.parseFloat(getComputedStyle(container).gap || "0") ||
|
||||
24;
|
||||
|
||||
itemWidthRef.current = card.getBoundingClientRect().width + gap;
|
||||
|
||||
// Calculate page count based on how many cards are visible at once
|
||||
const containerWidth = container.clientWidth;
|
||||
const visibleCards = Math.max(1, Math.floor(containerWidth / itemWidthRef.current));
|
||||
setPageCount(Math.max(1, services.length - visibleCards + 1));
|
||||
}, []);
|
||||
|
||||
const scrollByOne = useCallback((direction: 1 | -1) => {
|
||||
const container = carouselRef.current;
|
||||
if (!container || !itemWidthRef.current || isScrollingRef.current) return;
|
||||
|
||||
isScrollingRef.current = true;
|
||||
|
||||
container.scrollBy({
|
||||
left: direction * itemWidthRef.current,
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
isScrollingRef.current = false;
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
const container = carouselRef.current;
|
||||
if (!container || !itemWidthRef.current) return;
|
||||
|
||||
container.scrollTo({
|
||||
left: index * itemWidthRef.current,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateCurrentSlide = useCallback(() => {
|
||||
const container = carouselRef.current;
|
||||
if (!container || !itemWidthRef.current) return;
|
||||
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const newIndex = Math.round(scrollLeft / itemWidthRef.current);
|
||||
setCurrentSlide(Math.max(0, Math.min(newIndex, services.length - 1)));
|
||||
}, []);
|
||||
|
||||
const startAutoScroll = useCallback(() => {
|
||||
if (autoScrollTimerRef.current) {
|
||||
clearInterval(autoScrollTimerRef.current);
|
||||
}
|
||||
|
||||
autoScrollTimerRef.current = setInterval(() => {
|
||||
scrollByOne(1);
|
||||
}, 5000);
|
||||
}, [scrollByOne]);
|
||||
|
||||
const stopAutoScroll = useCallback(() => {
|
||||
if (autoScrollTimerRef.current) {
|
||||
clearInterval(autoScrollTimerRef.current);
|
||||
autoScrollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) {
|
||||
touchStartXRef.current = touch.clientX;
|
||||
touchEndXRef.current = touch.clientX;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) {
|
||||
touchEndXRef.current = touch.clientX;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
const diff = touchStartXRef.current - touchEndXRef.current;
|
||||
const minSwipeDistance = 50;
|
||||
|
||||
if (Math.abs(diff) > minSwipeDistance) {
|
||||
if (diff > 0) {
|
||||
scrollByOne(1);
|
||||
} else {
|
||||
scrollByOne(-1);
|
||||
}
|
||||
}
|
||||
startAutoScroll();
|
||||
}, [scrollByOne, startAutoScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
computeItemWidth();
|
||||
|
||||
const handleResize = () => computeItemWidth();
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
startAutoScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
stopAutoScroll();
|
||||
};
|
||||
}, [computeItemWidth, startAutoScroll, stopAutoScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = carouselRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
updateCurrentSlide();
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", handleScroll);
|
||||
}, [updateCurrentSlide]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
stopAutoScroll();
|
||||
}, [stopAutoScroll]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
startAutoScroll();
|
||||
}, [startAutoScroll]);
|
||||
|
||||
const handlePrevClick = useCallback(() => {
|
||||
scrollByOne(-1);
|
||||
startAutoScroll();
|
||||
}, [scrollByOne, startAutoScroll]);
|
||||
|
||||
const handleNextClick = useCallback(() => {
|
||||
scrollByOne(1);
|
||||
startAutoScroll();
|
||||
}, [scrollByOne, startAutoScroll]);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={solutionsRef as React.RefObject<HTMLElement>}
|
||||
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft py-12 sm:py-14 transition-all duration-700 ${
|
||||
solutionsInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
{/* Gradient fade to next section */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-background pointer-events-none" />
|
||||
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
||||
{/* Header row with title + nav arrows */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-3xl sm:text-4xl font-extrabold text-foreground">Solutions</h2>
|
||||
|
||||
{/* Desktop navigation arrows — inline with the heading */}
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous solution"
|
||||
onClick={handlePrevClick}
|
||||
className="h-10 w-10 rounded-full border border-border/80 bg-card text-foreground shadow-sm hover:bg-muted transition-colors active:scale-95 flex items-center justify-center"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next solution"
|
||||
onClick={handleNextClick}
|
||||
className="h-10 w-10 rounded-full border border-border/80 bg-card text-foreground shadow-sm hover:bg-muted transition-colors active:scale-95 flex items-center justify-center"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Carousel track */}
|
||||
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex gap-6 overflow-x-auto scroll-smooth snap-x snap-mandatory"
|
||||
style={{ scrollbarWidth: "none", WebkitOverflowScrolling: "touch" }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{services.map((service, index) => (
|
||||
<Link
|
||||
key={`${service.title}-${index}`}
|
||||
href={service.href}
|
||||
className="group flex-shrink-0 w-[280px] sm:w-[300px] snap-start"
|
||||
>
|
||||
<article
|
||||
data-service-card
|
||||
className="h-full rounded-2xl bg-card px-6 py-8 shadow-md border border-border/60 transition-all duration-300 group-hover:-translate-y-1 group-hover:shadow-lg flex flex-col"
|
||||
>
|
||||
<div className="mb-5 w-14 h-14 rounded-xl bg-primary/8 flex items-center justify-center">
|
||||
{service.icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-foreground mb-2 font-display">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed flex-grow">
|
||||
{service.description}
|
||||
</p>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress dots + mobile arrows */}
|
||||
<div className="flex items-center justify-center gap-4 mt-6">
|
||||
{/* Mobile-only prev arrow */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous solution"
|
||||
onClick={handlePrevClick}
|
||||
className="sm:hidden h-9 w-9 rounded-full border border-border/80 bg-card text-foreground shadow-sm hover:bg-muted transition-colors active:scale-95 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Dots — one per scroll page, not per card */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const activeDot = Math.min(currentSlide, pageCount - 1);
|
||||
return Array.from({ length: pageCount }).map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
activeDot === index
|
||||
? "bg-primary w-8"
|
||||
: "bg-foreground/20 w-2 hover:bg-foreground/40"
|
||||
}`}
|
||||
onClick={() => scrollToIndex(index)}
|
||||
aria-label={`Go to page ${index + 1}`}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Mobile-only next arrow */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next solution"
|
||||
onClick={handleNextClick}
|
||||
className="sm:hidden h-9 w-9 rounded-full border border-border/80 bg-card text-foreground shadow-sm hover:bg-muted transition-colors active:scale-95 flex items-center justify-center flex-shrink-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { Clock, CreditCard, Globe, Users } from "lucide-react";
|
||||
|
||||
const trustItems = [
|
||||
{ icon: Clock, label: "20+ Years" },
|
||||
{ icon: Globe, label: "Full English" },
|
||||
{ icon: CreditCard, label: "Foreign Cards" },
|
||||
{ icon: Users, label: "10,000+ Customers" },
|
||||
];
|
||||
|
||||
export function TrustStrip() {
|
||||
return (
|
||||
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 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="grid grid-cols-2 gap-4 sm:flex sm:justify-between">
|
||||
{trustItems.map(({ icon: Icon, label }) => (
|
||||
<div key={label} className="flex items-center gap-2.5">
|
||||
<Icon className="h-5 w-5 text-primary shrink-0" />
|
||||
<span className="text-sm font-semibold text-foreground">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -5,29 +5,31 @@ import Link from "next/link";
|
||||
import { ArrowRight, BadgeCheck } from "lucide-react";
|
||||
import { useInView } from "@/features/landing-page/hooks";
|
||||
|
||||
export function TrustSection() {
|
||||
const [trustRef, trustInView] = useInView();
|
||||
const trustPoints = [
|
||||
"Full English support, no Japanese needed",
|
||||
"Foreign credit cards accepted",
|
||||
"Bilingual contracts and documentation",
|
||||
];
|
||||
|
||||
export function WhyUsSection() {
|
||||
const [ref, isInView] = useInView();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={trustRef as React.RefObject<HTMLElement>}
|
||||
ref={ref as React.RefObject<HTMLElement>}
|
||||
className={`relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-background transition-all duration-700 ${
|
||||
trustInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
>
|
||||
{/* Gradient fade to next section */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-b from-transparent to-primary-soft pointer-events-none" />
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.1fr] gap-10 lg:gap-14 items-center">
|
||||
<div className="relative w-full overflow-hidden rounded-2xl shadow-md border border-border/60 bg-card aspect-[4/5]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-14 items-center">
|
||||
<div className="relative w-full overflow-hidden rounded-2xl shadow-md border border-border/60 bg-card aspect-[3/2]">
|
||||
<Image
|
||||
src="/assets/images/Why_us.png"
|
||||
alt="Team collaborating with trust and excellence"
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 40vw"
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
@ -42,11 +44,7 @@ export function TrustSection() {
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 text-foreground">
|
||||
{[
|
||||
"Full English support, no Japanese needed",
|
||||
"Foreign credit cards accepted",
|
||||
"Bilingual contracts and documentation",
|
||||
].map(item => (
|
||||
{trustPoints.map(item => (
|
||||
<li key={item} className="flex items-center gap-3 text-base font-semibold">
|
||||
<BadgeCheck className="h-5 w-5 text-primary" />
|
||||
<span>{item}</span>
|
||||
@ -1,10 +1,9 @@
|
||||
// Landing page sections
|
||||
export { HeroSection } from "./HeroSection";
|
||||
export { TrustSection } from "./TrustSection";
|
||||
export { SolutionsCarousel } from "./SolutionsCarousel";
|
||||
export { PopularServicesSection } from "./PopularServicesSection";
|
||||
export { RemoteSupportSection } from "./RemoteSupportSection";
|
||||
export { ContactSection } from "./ContactSection";
|
||||
export { TrustStrip } from "./TrustStrip";
|
||||
export { ServicesGrid } from "./ServicesGrid";
|
||||
export { WhyUsSection } from "./WhyUsSection";
|
||||
export { CTABanner } from "./CTABanner";
|
||||
|
||||
// Trust indicators
|
||||
export { TrustBadge } from "./TrustBadge";
|
||||
@ -14,7 +13,3 @@ export { TrustIndicators } from "./TrustIndicators";
|
||||
export { ValuePropCard } from "./ValuePropCard";
|
||||
export { FloatingGlassCard } from "./FloatingGlassCard";
|
||||
export { AnimatedBackground } from "./AnimatedBackground";
|
||||
|
||||
// NOTE: ServiceCard components have been consolidated into @/components/molecules/ServiceCard
|
||||
// Use: import { ServiceCard } from "@/components/molecules/ServiceCard"
|
||||
// The unified ServiceCard supports variants: 'default' | 'featured' | 'minimal' | 'bento-sm' | 'bento-md' | 'bento-lg'
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
export {
|
||||
type ServiceCategory,
|
||||
type ServiceItem,
|
||||
type LandingServiceItem,
|
||||
personalServices,
|
||||
businessServices,
|
||||
services,
|
||||
supportDownloads,
|
||||
mobileQuickServices,
|
||||
landingServices,
|
||||
} from "./services";
|
||||
|
||||
export {
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
Wifi,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import type { ServiceCardAccentColor } from "@/components/molecules";
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@ -137,3 +138,50 @@ export const supportDownloads = [
|
||||
|
||||
// Mobile quick services (top 3 most popular)
|
||||
export const mobileQuickServices = personalServices.slice(0, 3);
|
||||
|
||||
// =============================================================================
|
||||
// LANDING PAGE SERVICES (for ServicesGrid)
|
||||
// =============================================================================
|
||||
|
||||
export interface LandingServiceItem {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
href: string;
|
||||
accentColor: ServiceCardAccentColor;
|
||||
}
|
||||
|
||||
export const landingServices: LandingServiceItem[] = [
|
||||
{
|
||||
title: "Internet Plans",
|
||||
description:
|
||||
"High-speed NTT fiber with English installation support. No Japanese paperwork needed.",
|
||||
icon: <Wifi className="h-6 w-6" />,
|
||||
href: "/services/internet",
|
||||
accentColor: "blue",
|
||||
},
|
||||
{
|
||||
title: "Phone Plans",
|
||||
description:
|
||||
"SIM cards on Japan's best network. Foreign credit cards accepted, no hanko required.",
|
||||
icon: <Smartphone className="h-6 w-6" />,
|
||||
href: "/services/sim",
|
||||
accentColor: "green",
|
||||
},
|
||||
{
|
||||
title: "VPN Service",
|
||||
description:
|
||||
"Stream your favorite shows from home. Pre-configured router, just plug in and watch.",
|
||||
icon: <Lock className="h-6 w-6" />,
|
||||
href: "/services/vpn",
|
||||
accentColor: "purple",
|
||||
},
|
||||
{
|
||||
title: "Business Solutions",
|
||||
description:
|
||||
"Enterprise IT for international companies. Dedicated internet, networks, and bilingual support.",
|
||||
icon: <Building2 className="h-6 w-6" />,
|
||||
href: "/services/business",
|
||||
accentColor: "orange",
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export { useInView } from "./useInView";
|
||||
export { useContactForm } from "./useContactForm";
|
||||
export { useStickyCta } from "./useStickyCta";
|
||||
|
||||
33
apps/portal/src/features/landing-page/hooks/useStickyCta.ts
Normal file
33
apps/portal/src/features/landing-page/hooks/useStickyCta.ts
Normal file
@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* useStickyCta - Tracks hero CTA visibility for sticky mobile CTA.
|
||||
*
|
||||
* Uses IntersectionObserver to detect when the hero CTA scrolls out of view,
|
||||
* returning a boolean to control showing/hiding the sticky bottom CTA.
|
||||
*/
|
||||
export function useStickyCta() {
|
||||
const heroCTARef = useRef<HTMLDivElement>(null);
|
||||
const [showStickyCTA, setShowStickyCTA] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ctaElement = heroCTARef.current;
|
||||
if (!ctaElement) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry) {
|
||||
setShowStickyCTA(!entry.isIntersecting);
|
||||
}
|
||||
},
|
||||
{ threshold: 0 }
|
||||
);
|
||||
|
||||
observer.observe(ctaElement);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { heroCTARef, showStickyCTA };
|
||||
}
|
||||
@ -2,52 +2,62 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
|
||||
export function PublicLandingLoadingView() {
|
||||
return (
|
||||
<div className="space-y-0 pb-8 pt-0 sm:pt-0">
|
||||
<div className="space-y-0 pb-8 pt-0">
|
||||
{/* Hero Section Skeleton */}
|
||||
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-surface-sunken py-12 sm:py-16">
|
||||
<div className="mx-auto max-w-6xl grid grid-cols-1 lg:grid-cols-[1.05fr_minmax(0,0.95fr)] items-center gap-10 lg:gap-16 px-6 sm:px-10 lg:px-14 pt-0">
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-72 max-w-full rounded-md" />
|
||||
<Skeleton className="h-10 w-80 max-w-full rounded-md" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full rounded-md" />
|
||||
<Skeleton className="h-4 w-4/5 rounded-md" />
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Skeleton className="h-12 w-48 rounded-full" />
|
||||
<Skeleton className="h-12 w-48 rounded-full" />
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
<Skeleton className="h-10 w-72 mx-auto rounded-md" />
|
||||
<Skeleton className="h-10 w-56 mx-auto rounded-md" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Skeleton className="w-full aspect-[4/3] rounded-2xl" />
|
||||
<div className="space-y-2 max-w-2xl mx-auto">
|
||||
<Skeleton className="h-4 w-full rounded-md" />
|
||||
<Skeleton className="h-4 w-4/5 mx-auto rounded-md" />
|
||||
</div>
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Skeleton className="h-12 w-48 rounded-full" />
|
||||
<Skeleton className="h-12 w-48 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Solutions Carousel Skeleton */}
|
||||
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft py-12 sm:py-14">
|
||||
<div className="mx-auto max-w-7xl px-6 sm:px-10 lg:px-14 space-y-6">
|
||||
<Skeleton className="h-10 w-40 rounded-md" />
|
||||
<div className="flex gap-6 overflow-hidden">
|
||||
{/* 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">
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14 py-5">
|
||||
<div className="flex justify-between">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<div key={idx} className="w-[260px] flex-shrink-0">
|
||||
<Skeleton className="h-64 w-full rounded-3xl" />
|
||||
<div key={idx} className="flex items-center gap-2.5">
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-4 w-24 rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trust and Excellence Skeleton */}
|
||||
{/* ServicesGrid Skeleton */}
|
||||
<section className="py-14 sm:py-16">
|
||||
<div className="mx-auto max-w-6xl px-6 sm:px-10 lg:px-14">
|
||||
<div className="text-center mb-10 space-y-2">
|
||||
<Skeleton className="h-8 w-40 mx-auto rounded-md" />
|
||||
<Skeleton className="h-4 w-64 mx-auto rounded-md" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<Skeleton key={idx} className="h-52 w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* WhyUsSection Skeleton */}
|
||||
<section className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 py-14 sm:py-16">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1.1fr] gap-10 lg:gap-14 items-center">
|
||||
<Skeleton className="h-full w-full rounded-2xl min-h-[320px]" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-14 items-center">
|
||||
<Skeleton className="w-full aspect-[3/2] rounded-2xl" />
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-36 rounded-md" />
|
||||
<Skeleton className="h-10 w-3/4 rounded-md" />
|
||||
<Skeleton className="h-10 w-1/2 rounded-md" />
|
||||
<Skeleton className="h-8 w-3/4 rounded-md" />
|
||||
<Skeleton className="h-6 w-full rounded-md" />
|
||||
<Skeleton className="h-6 w-4/5 rounded-md" />
|
||||
<div className="space-y-3 pt-2">
|
||||
{Array.from({ length: 3 }).map((_, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
@ -61,46 +71,15 @@ export function PublicLandingLoadingView() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Support Downloads Skeleton */}
|
||||
<section className="max-w-5xl mx-auto px-6 sm:px-10 lg:px-14 pb-16">
|
||||
<Skeleton className="h-10 w-40 mx-auto rounded-md mb-10" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8 sm:gap-10">
|
||||
{Array.from({ length: 2 }).map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-2xl border border-border/60 bg-card p-6 shadow-sm space-y-4"
|
||||
>
|
||||
<Skeleton className="h-5 w-32 mx-auto rounded-md" />
|
||||
<Skeleton className="h-24 w-24 mx-auto rounded-full" />
|
||||
<Skeleton className="h-9 w-32 mx-auto rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact Skeleton */}
|
||||
<section className="relative left-1/2 right-1/2 w-screen -translate-x-1/2 bg-primary-soft py-14 sm:py-16">
|
||||
<div className="max-w-6xl mx-auto px-6 sm:px-10 lg:px-14 space-y-6">
|
||||
<Skeleton className="h-10 w-48 rounded-md" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1.1fr_0.9fr] gap-10 lg:gap-12">
|
||||
<div className="rounded-2xl bg-card shadow-sm border border-border/60 p-6 sm:p-8 space-y-4">
|
||||
<Skeleton className="h-8 w-48 rounded-md" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-11 w-full rounded-md" />
|
||||
<Skeleton className="h-11 w-full rounded-md" />
|
||||
</div>
|
||||
<Skeleton className="h-11 w-full rounded-md" />
|
||||
<Skeleton className="h-11 w-full rounded-md" />
|
||||
<Skeleton className="h-11 w-full rounded-md" />
|
||||
<Skeleton className="h-11 w-full rounded-md" />
|
||||
<Skeleton className="h-28 w-full rounded-md" />
|
||||
<Skeleton className="h-11 w-full rounded-md" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-48 w-full rounded-2xl" />
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
<Skeleton className="h-20 w-full rounded-2xl" />
|
||||
</div>
|
||||
{/* CTABanner Skeleton */}
|
||||
<section 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 py-14 sm:py-16 text-center space-y-4">
|
||||
<Skeleton className="h-8 w-64 mx-auto rounded-md" />
|
||||
<Skeleton className="h-4 w-80 mx-auto rounded-md" />
|
||||
<Skeleton className="h-6 w-36 mx-auto rounded-md" />
|
||||
<div className="flex justify-center gap-3 pt-2">
|
||||
<Skeleton className="h-12 w-48 rounded-full" />
|
||||
<Skeleton className="h-12 w-40 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -2,47 +2,25 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useContactForm } from "@/features/landing-page/hooks";
|
||||
import { useStickyCta } from "@/features/landing-page/hooks";
|
||||
import {
|
||||
HeroSection,
|
||||
TrustSection,
|
||||
SolutionsCarousel,
|
||||
PopularServicesSection,
|
||||
RemoteSupportSection,
|
||||
ContactSection,
|
||||
TrustStrip,
|
||||
ServicesGrid,
|
||||
WhyUsSection,
|
||||
CTABanner,
|
||||
} from "@/features/landing-page/components";
|
||||
|
||||
export function PublicLandingView() {
|
||||
const {
|
||||
heroCTARef,
|
||||
showStickyCTA,
|
||||
formData,
|
||||
formErrors,
|
||||
formTouched,
|
||||
isSubmitting,
|
||||
submitStatus,
|
||||
handleInputChange,
|
||||
handleInputBlur,
|
||||
handleSubmit,
|
||||
} = useContactForm();
|
||||
const { heroCTARef, showStickyCTA } = useStickyCta();
|
||||
|
||||
return (
|
||||
<div className="space-y-0 pb-8 pt-0">
|
||||
<HeroSection heroCTARef={heroCTARef} />
|
||||
<TrustSection />
|
||||
<SolutionsCarousel />
|
||||
<PopularServicesSection />
|
||||
<RemoteSupportSection />
|
||||
<ContactSection
|
||||
formData={formData}
|
||||
formErrors={formErrors}
|
||||
formTouched={formTouched}
|
||||
isSubmitting={isSubmitting}
|
||||
submitStatus={submitStatus}
|
||||
handleInputChange={handleInputChange}
|
||||
handleInputBlur={handleInputBlur}
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
<TrustStrip />
|
||||
<ServicesGrid />
|
||||
<WhyUsSection />
|
||||
<CTABanner />
|
||||
|
||||
{/* Sticky Mobile CTA */}
|
||||
{showStickyCTA && (
|
||||
|
||||
@ -35,7 +35,9 @@ export function HowItWorks({
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="text-display-sm font-display text-foreground">{title}</h2>
|
||||
<h2 className="text-display-sm font-display font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Steps Container */}
|
||||
|
||||
@ -64,7 +64,9 @@ export function ServiceCTA({
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h2 className="text-display-sm font-display text-foreground mb-3">{headline}</h2>
|
||||
<h2 className="text-display-sm font-display font-semibold leading-tight text-foreground mb-3">
|
||||
{headline}
|
||||
</h2>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-base text-muted-foreground mb-6 max-w-md mx-auto">{description}</p>
|
||||
|
||||
@ -84,7 +84,9 @@ export function ServiceFAQ({
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="text-display-sm font-display text-foreground">{title}</h2>
|
||||
<h2 className="text-display-sm font-display font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* FAQ Container */}
|
||||
|
||||
@ -57,7 +57,7 @@ export function ServicesHero({
|
||||
) : null}
|
||||
<h1
|
||||
className={cn(
|
||||
"text-display-xl text-foreground leading-tight font-bold",
|
||||
"text-display-xl text-foreground leading-display font-extrabold",
|
||||
displayFont && "font-display",
|
||||
animationClasses
|
||||
)}
|
||||
|
||||
@ -157,7 +157,7 @@ export function VpnPlansContent({
|
||||
</span>
|
||||
</div>
|
||||
<h1
|
||||
className="text-display-md md:text-display-lg font-display text-foreground tracking-tight animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||
className="text-display-md md:text-display-lg font-display font-semibold md:font-bold leading-tight md:leading-display text-foreground animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
Stream Content from Abroad
|
||||
@ -208,7 +208,9 @@ export function VpnPlansContent({
|
||||
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-2">
|
||||
Choose Your Region
|
||||
</p>
|
||||
<h2 className="text-display-sm font-display text-foreground">Available Plans</h2>
|
||||
<h2 className="text-display-sm font-display font-semibold leading-tight text-foreground">
|
||||
Available Plans
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Select one region per router rental
|
||||
</p>
|
||||
|
||||
@ -137,44 +137,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== DISPLAY TYPOGRAPHY (Tailwind v4 @utility) ===== */
|
||||
/* Only set font-size + letter-spacing. Font weight, line-height, and
|
||||
font-family are explicit Tailwind classes at the call site so they
|
||||
can be overridden without cascade conflicts. */
|
||||
|
||||
@utility text-display-xl {
|
||||
font-size: var(--cp-text-display-xl);
|
||||
letter-spacing: var(--cp-tracking-tight);
|
||||
}
|
||||
|
||||
@utility text-display-lg {
|
||||
font-size: var(--cp-text-display-lg);
|
||||
letter-spacing: var(--cp-tracking-tight);
|
||||
}
|
||||
|
||||
@utility text-display-md {
|
||||
font-size: var(--cp-text-display-md);
|
||||
letter-spacing: var(--cp-tracking-tight);
|
||||
}
|
||||
|
||||
@utility text-display-sm {
|
||||
font-size: var(--cp-text-display-sm);
|
||||
letter-spacing: var(--cp-tracking-tight);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* ===== DISPLAY TYPOGRAPHY ===== */
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.text-display-xl {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--cp-text-display-xl);
|
||||
font-weight: var(--cp-font-extrabold);
|
||||
line-height: var(--cp-leading-display);
|
||||
letter-spacing: var(--cp-tracking-tight);
|
||||
}
|
||||
|
||||
.text-display-lg {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--cp-text-display-lg);
|
||||
font-weight: var(--cp-font-bold);
|
||||
line-height: var(--cp-leading-display);
|
||||
letter-spacing: var(--cp-tracking-tight);
|
||||
}
|
||||
|
||||
.text-display-md {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--cp-text-display-md);
|
||||
font-weight: var(--cp-font-semibold);
|
||||
line-height: var(--cp-leading-tight);
|
||||
letter-spacing: var(--cp-tracking-tight);
|
||||
}
|
||||
|
||||
.text-display-sm {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--cp-text-display-sm);
|
||||
font-weight: var(--cp-font-semibold);
|
||||
line-height: var(--cp-leading-tight);
|
||||
letter-spacing: var(--cp-tracking-tight);
|
||||
}
|
||||
|
||||
/* ===== PAGE ENTRANCE ANIMATIONS ===== */
|
||||
.cp-animate-in {
|
||||
animation: cp-fade-up var(--cp-duration-slow) var(--cp-ease-out) forwards;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user