first commit
This commit is contained in:
112
components/ContactForm.tsx
Normal file
112
components/ContactForm.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
import React from 'react'
|
||||
import CustomButton from './CustomButton'
|
||||
import { useForm, SubmitHandler } from "react-hook-form"
|
||||
import { postMessage } from '@/utils'
|
||||
import { MessageProps } from '@/types'
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
type Props = {
|
||||
startLoading: () => void
|
||||
stopLoading: () => void
|
||||
}
|
||||
const ContactForm = ({ startLoading, stopLoading }: Props) => {
|
||||
|
||||
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<MessageProps>()
|
||||
const onSubmit: SubmitHandler<MessageProps> = async (data) => {
|
||||
try {
|
||||
startLoading; // Set loading state to true
|
||||
const result = await postMessage(data);
|
||||
if (result.success) {
|
||||
toast.success('已收到您的訊息,我們會盡快回覆您!');
|
||||
reset(); // Reset form fields
|
||||
// Reset form or perform any other actions on success
|
||||
} else {
|
||||
toast.error('Failed to send message. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
toast.error('An error occurred. Please try again later.');
|
||||
} finally {
|
||||
stopLoading; // Set loading state back to false
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="w-full flex flex-col h-auto items-center justify-center relative " >
|
||||
<div className="text-4xl sm:text-3xl lg:text-5xl text-center mt-[10vh]">
|
||||
讓我們知道您的想法
|
||||
</div>
|
||||
<div className="bg-[url('/images/014.png')] absolute bottom--10 inset-0 h-auto w-full bg-contain bg-bottom bg-no-repeat hidden lg:block"></div>
|
||||
|
||||
<div className="font-[sans-serif] w-full md:h-[62vh] max-w-5xl relative bg-transparent lg:bg-white shadow-none lg:shadow-[0_2px_10px_-3px_rgba(6,81,237,0.3)] rounded-3xl overflow-hidden lg:mt-14 lg:mb-56 sm:my-15 max-md:my-0 sm:mx-20">
|
||||
|
||||
|
||||
<div className="grid lg:grid-cols-10 ">
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex rounded-tl-3xl rounded-bl-3xl h-[62vh] lg:col-span-6 items-center justify-center">
|
||||
|
||||
<div className="space-y-3 w-full mx-11">
|
||||
<div className="grid md:grid-cols-2 space-y-3">
|
||||
<div className=" md:mr-3 mt-3">
|
||||
<p className='text-sm'>姓名</p>
|
||||
<input type='text'
|
||||
{...register("name", { required: true })}
|
||||
className="w-full mt-3 max-lg:bg-gray-100 md:bg-gray-100 max-sm:bg-[#F6E2E3] rounded-md py-3 px-4 text-sm outline-[#EA004F] md:focus-within:bg-transparent border border-gray-200" />
|
||||
</div>
|
||||
<div >
|
||||
<p className='text-sm'>電話</p>
|
||||
<input type='phone'
|
||||
{...register("phone", { required: true })}
|
||||
className="w-full mt-3 max-lg:bg-gray-100 md:bg-gray-100 max-sm:bg-[#F6E2E3] rounded-md py-3 px-4 text-sm outline-[#EA004F] md:focus-within:bg-transparent border border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<p className='text-sm'>電郵</p>
|
||||
<input type='email'
|
||||
{...register("email", { required: true })}
|
||||
className="w-full mt-3 max-lg:bg-gray-100 md:bg-gray-100 max-sm:bg-[#F6E2E3] rounded-md py-3 px-4 text-sm outline-[#EA004F] md:focus-within:bg-transparent border border-gray-200" />
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<p className='text-sm'>訊息</p>
|
||||
<textarea
|
||||
{...register("message", { required: true })}
|
||||
className="w-full mt-3 h-36 max-lg:bg-gray-100 md:bg-gray-100 max-sm:bg-[#F6E2E3] rounded-md px-4 text-sm pt-3 outline-[#EA004F] md:focus-within:bg-transparent border border-gray-200"></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`middle none center rounded-full md:px-8 md:py-3 text-sm max-sm:w-full py-3 px-8 bg-mainColor font-bold uppercase text-white shadow-md shadow-pink-500/20 transition-all hover:shadow-lg hover:shadow-pink-500/40`}
|
||||
data-ripple-light="true"
|
||||
|
||||
>
|
||||
{'發送'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="text-center bg-[url('/images/contact.png')] bg-cover h-auto w-full lg:col-span-4 sm:hidden md:hidden lg:block">
|
||||
{/* <img src="/images/contact.png" /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContactForm
|
124
components/Course/Accordion.tsx
Normal file
124
components/Course/Accordion.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
import React from 'react'
|
||||
import Collapse from './Collapse'
|
||||
import ScheduleCollapse from './ScheduleCollapse'
|
||||
import { CoursesProps, ScheduleProps, RerganizedScheduleProps } from '@/types'
|
||||
|
||||
|
||||
async function reorganizedSchedule(schedule: ScheduleProps[]) {
|
||||
|
||||
const sortedSchedule = schedule.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
// Then, reorganize the sorted schedule
|
||||
const reorganizedSchedule = sortedSchedule.reduce<Record<string, { yearAndMonth: string; schedule: ScheduleProps[] }>>((acc, item) => {
|
||||
const date = new Date(item.date);
|
||||
const yearMonth = `${date.getFullYear()}年${String(date.getMonth() + 1).padStart(2, '0')}月`;
|
||||
|
||||
if (!acc[yearMonth]) {
|
||||
acc[yearMonth] = {
|
||||
yearAndMonth: yearMonth,
|
||||
schedule: []
|
||||
};
|
||||
}
|
||||
|
||||
acc[yearMonth].schedule.push(item);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.values(reorganizedSchedule);
|
||||
}
|
||||
|
||||
|
||||
|
||||
const Accordion = async ({ courseData }: { courseData: CoursesProps }) => {
|
||||
const newSchedule = await reorganizedSchedule(courseData.schedule)
|
||||
|
||||
const fakeData = [
|
||||
{
|
||||
"yearAndMonth": "2024年10月",
|
||||
"schedule": [
|
||||
{
|
||||
"info2": "A room, 14F, Sha Tin Market, N.T.",
|
||||
"info1": "11:00-12:00, 13:00-14:00, 15:00-16:00, 17:00-18:00",
|
||||
"id": "e19a7934-82fe-4027-aca6-57a419e9623e",
|
||||
"title": "A班",
|
||||
"date": "2024-10-05T01:30:00Z",
|
||||
"course_id": "29ab193d-2927-459d-9277-5520771b2dd6"
|
||||
},
|
||||
{
|
||||
"info2": "Room B, Sheung Shui Village, N.T.",
|
||||
"info1": "11:00-12:00, 13:00-14:00, 15:00-16:00, 17:00-18:00",
|
||||
"id": "dc49a1b6-1c1f-44d6-bc52-b757aeba7b5b",
|
||||
"title": "B班",
|
||||
"date": "2024-10-05T01:31:00Z",
|
||||
"course_id": "29ab193d-2927-459d-9277-5520771b2dd6"
|
||||
},
|
||||
{
|
||||
"info2": "Room B, Sheung Shui Village, N.T.",
|
||||
"info1": "11:00-12:00, 13:00-14:00, 15:00-16:00, 17:00-18:00",
|
||||
"id": "d4785a90-311b-4a91-8fb7-beb077245f4f",
|
||||
"title": "A班",
|
||||
"date": "2024-10-06T01:31:00Z",
|
||||
"course_id": "29ab193d-2927-459d-9277-5520771b2dd6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"yearAndMonth": "2024年11月",
|
||||
"schedule": [
|
||||
{
|
||||
"info2": "Room B, Sheung Shui Village, N.T.",
|
||||
"info1": "11:00-12:00, 13:00-14:00, 15:00-16:00, 17:00-18:00",
|
||||
"id": "5c88b2a7-6b4d-455f-b341-1fec3173d208",
|
||||
"title": "A班",
|
||||
"date": "2024-11-06T01:31:00Z",
|
||||
"course_id": "29ab193d-2927-459d-9277-5520771b2dd6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"yearAndMonth": "2025年01月",
|
||||
"schedule": [
|
||||
{
|
||||
"info2": "Room B, Sheung Shui Village, N.T.",
|
||||
"info1": "11:00-12:00, 13:00-14:00, 15:00-16:00, 17:00-18:00",
|
||||
"id": "8a30dec2-63ed-4237-8f5b-177c42f29dda",
|
||||
"title": "B班",
|
||||
"date": "2025-01-04T01:31:00Z",
|
||||
"course_id": "29ab193d-2927-459d-9277-5520771b2dd6"
|
||||
},
|
||||
{
|
||||
"info2": "Room B, Sheung Shui Village, N.T.",
|
||||
"info1": "11:00-12:00, 13:00-14:00, 15:00-16:00, 17:00-18:00",
|
||||
"id": "86ddf906-499b-4dbd-967d-cc4dddd9f27b",
|
||||
"title": "A班",
|
||||
"date": "2025-01-05T01:31:00Z",
|
||||
"course_id": "29ab193d-2927-459d-9277-5520771b2dd6"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
console.log(newSchedule)
|
||||
return (
|
||||
<div className='relative flex flex-col w-full h-auto items-center pb-60'>
|
||||
<Collapse title='課程資訊' children={courseData.information} />
|
||||
<Collapse title='課程內容' children={courseData.contant} info={true} info_images={courseData.info_images} />
|
||||
<Collapse title='備註' children={courseData.remark} />
|
||||
<p className='text-5xl text-center mt-48'>
|
||||
{"課程時間表"}
|
||||
</p>
|
||||
{
|
||||
fakeData.map((item, index) => {
|
||||
return (
|
||||
<ScheduleCollapse key={index} title={item.yearAndMonth} rerganizedSchedule={{ yearAndMonth: item.yearAndMonth, schedules: item.schedule }} />
|
||||
)
|
||||
})
|
||||
}
|
||||
<div className="bg-[url('/images/014.png')] absolute bottom-0 inset-0 h-auto w-full bg-contain bg-bottom bg-no-repeat -z-10"></div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Accordion
|
24
components/Course/Banner.tsx
Normal file
24
components/Course/Banner.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { CoursesProps } from '@/types'
|
||||
const Banner = ({ courseData }: { courseData: CoursesProps }) => {
|
||||
console.log(courseData)
|
||||
return (
|
||||
<div className='relative flex w-full h-[40vh] lg:h-[70vh] bg-gradient-to-r from-[#FDB2B8] to-[#FEB3BA] items-center'>
|
||||
|
||||
<div className='flex-col flex lg:ml-48 ml-4 z-10'>
|
||||
<p className='text-black lg:text-5xl text-3xl font-bold text-center'>
|
||||
{courseData?.title}
|
||||
</p>
|
||||
<img src={"/images/line.png"} className='lg:w-[35vh] w-[20vh] h-auto object-cover mt-2' />
|
||||
</div>
|
||||
{/* <div className='absolute bottom-0 right-0 max-sm:-right-32 md:-right-32 lg:right-32 bg-[url("/images/kid2.png")] bg-cover bg-center bg-no-repeat h-full w-[80%]'></div> */}
|
||||
<img
|
||||
src={"/images/kid2.png"}
|
||||
alt="kid2"
|
||||
className="absolute bottom-0 right-0 lg:right-32 object-cover object-left h-full lg:w-auto :w-40"
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Banner
|
168
components/Course/Collapse.tsx
Normal file
168
components/Course/Collapse.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { cn } from '../utils'
|
||||
import { VscAdd, VscChromeMinimize } from "react-icons/vsc";
|
||||
import { imageProps, } from '@/types';
|
||||
import "slick-carousel/slick/slick.css";
|
||||
import "slick-carousel/slick/slick-theme.css";
|
||||
import Slider from "react-slick";
|
||||
import './slick.css'
|
||||
|
||||
interface CollapseProps {
|
||||
title: string
|
||||
children?: string
|
||||
className?: string
|
||||
info?: boolean
|
||||
info_images?: Array<imageProps>
|
||||
|
||||
|
||||
}
|
||||
|
||||
const Collapse: React.FC<CollapseProps> = ({ title, children, className, info, info_images, }) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
var settings2 = {
|
||||
arrows: false,
|
||||
infinite: true,
|
||||
//centerPadding: "30px",
|
||||
dots: true,
|
||||
speed: 500,
|
||||
slidesToShow: 3,
|
||||
slidesToScroll: 1,
|
||||
appendDots: (dots: any) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ul> {dots} </ul>
|
||||
</div>
|
||||
),
|
||||
dotsClass: 'dots_custom'
|
||||
};
|
||||
|
||||
var settings1 = {
|
||||
arrows: false,
|
||||
className: "center",
|
||||
centerMode: true,
|
||||
infinite: true,
|
||||
centerPadding: "30px",
|
||||
dots: true,
|
||||
speed: 500,
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
beforeChange: (current: number, next: number) => setCurrentSlide(next),
|
||||
appendDots: (dots: any) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ul> {dots} </ul>
|
||||
</div>
|
||||
),
|
||||
dotsClass: 'dots_custom'
|
||||
};
|
||||
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [height, setHeight] = useState<number | undefined>(0)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setHeight(ref.current?.scrollHeight)
|
||||
else setHeight(0)
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
children && children.length > 0 ? (
|
||||
<div className={cn('mt-6 rounded-3xl ', className)}>
|
||||
<button
|
||||
className={`flex justify-between items-center w-full p-6 text-left ${isOpen ? "rounded-t-lg bg-mainColor" : "rounded-lg bg-[#F2D6D5] "}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className={`text-xl font-bold ml-3 ${isOpen ? "text-white " : " text-black"}`}>{title}</span>
|
||||
{isOpen ? (
|
||||
<VscChromeMinimize className='text-white mr-3' />
|
||||
) : (
|
||||
<VscAdd className='text-black mr-3' />
|
||||
)}
|
||||
|
||||
</button>
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ height: height, }}
|
||||
className="flex relative overflow-hidden transition-[height] duration-300 ease-in-out "
|
||||
>
|
||||
<div className='relative flex flex-col bg-[#FAF6F5] rounded-b-lg w-[65vw] max-sm:w-[90vw]'>
|
||||
|
||||
{info ? (<div className='relative justify-between items-center '>
|
||||
|
||||
{info_images && info_images.length > 0 && (
|
||||
<div className="flex flex-col relative w-ful h-auto ">
|
||||
<div className='relative justify-between items-center hidden lg:flex pt-10'>
|
||||
<Slider className=" w-full h-[17vw] px-12 " {...settings2}>
|
||||
{info_images.map((image, index) => (
|
||||
<div key={index} className="relative w-full h-[13vw] px-1 transition-all justify-items-center ">
|
||||
<img
|
||||
src={`http://localhost/${image.image}`}
|
||||
alt={`Course Image ${index + 1}`}
|
||||
className="w-full h-full object-cover object-center "
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
</div>
|
||||
{/* <div className='relative flex sm:block md:block lg:hidden xl:hidden 2xl:hidden'> */}
|
||||
<div className='sm:block md:block lg:hidden xl:hidden 2xl:hidden pt-10'>
|
||||
<Slider {...settings1}>
|
||||
{info_images.map((image, index) => (
|
||||
<div key={index} className="px-4">
|
||||
<div key={index} className="relative h-[50vw] transition-all justify-items-center mb-16">
|
||||
<img
|
||||
src={`http://localhost/${image.image}`}
|
||||
alt={`Course Image ${index + 1}`}
|
||||
className="w-full h-full object-cover object-center "
|
||||
/>
|
||||
{index !== currentSlide && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white bg-opacity-50 transition-opacity duration-300 "
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center'
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>)
|
||||
:
|
||||
(<div></div>)}
|
||||
<div
|
||||
className='px-12 py-14 [&_*]:!text-[1.5rem] [&_*]:!text-gray-600 [&_*]:!bg-transparent'
|
||||
dangerouslySetInnerHTML={{ __html: children || '' }}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div></div>
|
||||
)
|
||||
)
|
||||
}
|
||||
export default Collapse
|
20
components/Course/Course.tsx
Normal file
20
components/Course/Course.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import Banner from './Banner'
|
||||
import LongDesc from './LongDesc'
|
||||
import CourseImagesSilder from './CourseImagesSilder'
|
||||
import Accordion from './Accordion'
|
||||
import { CoursesProps } from '@/types'
|
||||
import Footer from '../Footer'
|
||||
const Course = ({ course }: { course: CoursesProps }) => {
|
||||
return (
|
||||
<div className='bg-[#F6E8E8]'>
|
||||
<Banner courseData={course} />
|
||||
<LongDesc courseData={course} />
|
||||
<CourseImagesSilder courseData={course} />
|
||||
<Accordion courseData={course} />
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Course
|
158
components/Course/CourseImagesSilder.tsx
Normal file
158
components/Course/CourseImagesSilder.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
import React, { useState } from 'react'
|
||||
import "slick-carousel/slick/slick.css";
|
||||
import "slick-carousel/slick/slick-theme.css";
|
||||
import Slider from "react-slick";
|
||||
import './slick.css'
|
||||
import { CoursesProps, CoursesArrayProps } from "@/types";
|
||||
import { IoIosArrowForward, IoIosArrowBack } from "react-icons/io";
|
||||
|
||||
const SamplePrevArrow = (props: any) => {
|
||||
const { className, style, onClick } = props;
|
||||
return (
|
||||
<div
|
||||
id='prev'
|
||||
onClick={onClick}
|
||||
className="group flex h-12 w-12 rounded-full bg-[#F2D6D5] justify-center items-center absolute top-1/2 -translate-x-1/2 -left-8 transform -translate-y-1/2 z-10 cursor-pointer hover:bg-[#D60050]"
|
||||
|
||||
>
|
||||
<IoIosArrowBack className='text-black mr-1 group-hover:text-white transition-colors duration-200' size={30} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function SampleNextArrow(props: any) {
|
||||
const { className, style, onClick } = props;
|
||||
return (
|
||||
<div
|
||||
id='next'
|
||||
onClick={onClick}
|
||||
className="group flex h-12 w-12 rounded-full bg-[#F2D6D5] justify-center items-center absolute top-1/2 -translate-x-1/2 -right-20 transform -translate-y-1/2 z-10 cursor-pointer hover:bg-[#D60050]"
|
||||
>
|
||||
<IoIosArrowForward className='text-black ml-1 group-hover:text-white transition-colors duration-200' size={30} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CourseImagesSilder = ({ courseData }: { courseData: CoursesProps }) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
var settings = {
|
||||
arrows: false,
|
||||
className: "center",
|
||||
centerMode: true,
|
||||
infinite: true,
|
||||
centerPadding: "30px",
|
||||
dots: true,
|
||||
speed: 500,
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
beforeChange: (current: number, next: number) => setCurrentSlide(next),
|
||||
appendDots: (dots: any) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ul> {dots} </ul>
|
||||
</div>
|
||||
),
|
||||
dotsClass: 'dots_custom'
|
||||
};
|
||||
|
||||
var settings2 = {
|
||||
fade: true,
|
||||
centerMode: true,
|
||||
infinite: true,
|
||||
dots: true,
|
||||
speed: 500,
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
nextArrow: <SampleNextArrow to="next" />,
|
||||
prevArrow: <SamplePrevArrow to="prev" />,
|
||||
beforeChange: (current: number, next: number) => setCurrentSlide(next),
|
||||
appendDots: (dots: any) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ul> {dots} </ul>
|
||||
</div>
|
||||
),
|
||||
dotsClass: 'dots_custom'
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className='w-full h-auto my-20'>
|
||||
|
||||
<div className='relative mx-[15vw] justify-between items-center hidden lg:flex'>
|
||||
{courseData.images.length > 0 && (
|
||||
<div className="w-full ">
|
||||
<Slider className="w-full h-[40vw]" {...settings2}>
|
||||
{courseData.images.map((image, index) => (
|
||||
<div key={index} className="relative w-full h-[35vw] transition-all justify-items-center mb-28 ">
|
||||
<img
|
||||
src={`http://localhost/${image.image}`}
|
||||
alt={`Course Image ${index + 1}`}
|
||||
className="w-full h-full object-cover object-center rounded-xl "
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='relative flex justify-between items-center sm:block md:block lg:hidden xl:hidden 2xl:hidden'>
|
||||
{courseData.images.length > 0 && (
|
||||
<div className="w-full">
|
||||
<Slider {...settings}>
|
||||
{courseData.images.map((image, index) => {
|
||||
return (
|
||||
<div key={index} className="px-4">
|
||||
<div key={index} className="relative h-[38vw] transition-all justify-items-center mb-16">
|
||||
<img
|
||||
src={`http://localhost/${image.image}`}
|
||||
alt={`Course Image ${index + 1}`}
|
||||
className="w-full h-full object-cover object-center rounded-xl"
|
||||
/>
|
||||
{index !== currentSlide && (
|
||||
<div
|
||||
className="absolute inset-0 bg-white bg-opacity-50 transition-opacity duration-300 rounded-xl"
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center'
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Slider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default CourseImagesSilder
|
20
components/Course/LongDesc.tsx
Normal file
20
components/Course/LongDesc.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import { CoursesProps } from '@/types'
|
||||
const LongDesc = ({ courseData }: { courseData: CoursesProps }) => {
|
||||
return (
|
||||
<div className='w-full h-auto lg:mt-64 mt-20'>
|
||||
<div className='flex flex-col items-center justify-center mx-[5vw] lg:mx-[20vw]'>
|
||||
<p className='text-5xl text-center'>
|
||||
{courseData.title}
|
||||
</p>
|
||||
<div
|
||||
className='mt-10 [&_*]:!text-[1.5rem] [&_*]:!text-gray-600 [&_*]:!text-center'
|
||||
dangerouslySetInnerHTML={{ __html: courseData.long_description }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default LongDesc
|
75
components/Course/ScheduleCollapse.tsx
Normal file
75
components/Course/ScheduleCollapse.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { cn } from '../utils'
|
||||
import { VscAdd, VscChromeMinimize } from "react-icons/vsc";
|
||||
import { ScheduleProps } from '@/types';
|
||||
import moment from 'moment';
|
||||
|
||||
interface CollapseProps {
|
||||
title: string
|
||||
rerganizedSchedule?: {
|
||||
yearAndMonth: string
|
||||
schedules: ScheduleProps[]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const ScheduleCollapse: React.FC<CollapseProps> = ({ title, rerganizedSchedule }) => {
|
||||
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [height, setHeight] = useState<number | undefined>(0)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setHeight(ref.current?.scrollHeight)
|
||||
else setHeight(0)
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div className={cn('mt-6 rounded-3xl ')}>
|
||||
<button
|
||||
className={`flex justify-between items-center w-full p-6 text-left ${isOpen ? "rounded-t-lg bg-[#D60050]" : "rounded-lg bg-[#F2D6D5] "}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className={`text-xl font-bold ml-3 ${isOpen ? "text-white " : " text-black"}`}>{title}</span>
|
||||
{isOpen ? (
|
||||
<VscChromeMinimize className='text-white mr-3' />
|
||||
) : (
|
||||
<VscAdd className='text-black mr-3' />
|
||||
)}
|
||||
|
||||
</button>
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ height: height, }}
|
||||
className="flex relative overflow-hidden transition-[height] duration-300 ease-in-out "
|
||||
>
|
||||
<div className='relative flex flex-col bg-[#FAF6F5] rounded-b-lg w-[65vw] max-sm:w-[90vw]'>
|
||||
|
||||
|
||||
{rerganizedSchedule ? (
|
||||
<div className='px-12 py-14'>
|
||||
{rerganizedSchedule.schedules.map((schedule, index) => (
|
||||
<div key={index} className='mb-4'>
|
||||
<h3 className='text-xl font-bold'>
|
||||
{ moment.utc(schedule.date).format("MM月DD日")}
|
||||
</h3>
|
||||
<p className='text-gray-600'>{schedule.title}</p>
|
||||
<p className='text-gray-600'>{schedule.info1}</p>
|
||||
<p className='text-gray-600'>{schedule.info2}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
export default ScheduleCollapse
|
30
components/Course/slick.css
Normal file
30
components/Course/slick.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.dots_custom {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: auto 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dots_custom li {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dots_custom li button {
|
||||
border: none;
|
||||
background: #d1d1d1;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dots_custom li.slick-active button {
|
||||
background-color: #D60050;
|
||||
}
|
112
components/CoursesSilder.tsx
Normal file
112
components/CoursesSilder.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
import CustomButton from "./CustomButton";
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { CoursesProps, CoursesArrayProps } from "@/types";
|
||||
import CustomButtom from "./CustomButton";
|
||||
import "slick-carousel/slick/slick.css";
|
||||
import "slick-carousel/slick/slick-theme.css";
|
||||
import Slider from "react-slick";
|
||||
import './slick.css'
|
||||
import { GoDot } from "react-icons/go";
|
||||
//npm i --save-dev @types/react-slick
|
||||
|
||||
|
||||
|
||||
|
||||
const CoursesSilder = ({ courses }: { courses: CoursesProps[] }) => {
|
||||
var settings = {
|
||||
dots: true,
|
||||
infinite: true,
|
||||
speed: 500,
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
appendDots: (dots: any) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ul> {dots} </ul>
|
||||
</div>
|
||||
),
|
||||
dotsClass: 'dots_custom'
|
||||
};
|
||||
console.log(courses)
|
||||
return (
|
||||
<div>
|
||||
{courses.length > 0 ? (
|
||||
<div className=" bg-[#F6E8E9] items-center h-[100vh] w-full pt-30 transition-all duration-200">
|
||||
<Slider className="w-full" {...settings}>
|
||||
{courses.map((course, index) => {
|
||||
return (
|
||||
<div key={index} className=" bg-[#F6E8E9] h-auto pt-30 transition-all justify-items-center duration-20">
|
||||
<div className="hidden lg:grid lg:grid-cols-2 w-full mb-28">
|
||||
<div className="flex col-span-1 h-auto justify-self-end mr-20">
|
||||
<img
|
||||
src={`http://localhost/${course.images[0].image}`}
|
||||
alt="Course Image"
|
||||
className="w-[50vh] h-[70vh] object-cover object-center rounded-3xl shadow-md"
|
||||
style={{ aspectRatio: '1 / 1' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex col-span-1 h-auto justify-start items-center ml-20">
|
||||
<div className="flex flex-col items-start w-[50vh]">
|
||||
<p className="text-4xl">
|
||||
{course.title}
|
||||
</p>
|
||||
<p className="text-base line-clamp-3 overflow-hidden mt-8">
|
||||
{course.sort_description}
|
||||
</p>
|
||||
<div>
|
||||
<button
|
||||
className={`mt-16 middle none center rounded-full bg-[#D60050] h-12 w-28 text-base text-white shadow-md shadow-pink-500/20 transition-all hover:shadow-lg hover:shadow-pink-500/40`}
|
||||
data-ripple-light="true"
|
||||
>
|
||||
{"了解更多"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=" relative w-[50vh] h-[70vh] max-sm:w-[36vh] max-sm:h-[60vh] mt-20 mb-28 overflow-hidden rounded-3xl shadow-md sm:block md:block lg:hidden xl:hidden 2xl:hidden mx-auto">
|
||||
<img
|
||||
src={`http://localhost/${course.images[0].image}`}
|
||||
alt="Course Image"
|
||||
className="absolute inset-0 w-full h-full object-cover object-center "
|
||||
/>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-50 text-white p-6">
|
||||
<p className="text-2xl text-center mb-4">
|
||||
{course.title}
|
||||
</p>
|
||||
<p className="text-base line-clamp-3 overflow-hidden text-center mb-8">
|
||||
{course.sort_description}
|
||||
</p>
|
||||
<button
|
||||
className="rounded-full bg-white h-12 w-28 text-sm text-black shadow-md shadow-gray-400/20 transition-all hover:shadow-lg hover:shadow-gray-500/40"
|
||||
data-ripple-light="true"
|
||||
>
|
||||
{"了解更多"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Slider>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-10" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoursesSilder;
|
||||
|
17
components/CustomButton.tsx
Normal file
17
components/CustomButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
import React from 'react'
|
||||
import { CustomButtonProps } from '@/types'
|
||||
const CustomButton = ({ isDisabled, title, handleClick, text_and_button_size }: CustomButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
className={`middle none center rounded-full bg-[#D60050] ${text_and_button_size} font-bold uppercase text-white shadow-md shadow-pink-500/20 transition-all hover:shadow-lg hover:shadow-pink-500/40`}
|
||||
data-ripple-light="true"
|
||||
disabled={isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomButton
|
43
components/Footer.tsx
Normal file
43
components/Footer.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
import Link from 'next/link'
|
||||
import { FaFacebookF, FaYoutube } from "react-icons/fa";
|
||||
import { RiInstagramFill } from "react-icons/ri";
|
||||
import { SettingsProps } from '@/types';
|
||||
import { IoMenu } from "react-icons/io5";
|
||||
const Footer = ({ settings }: { settings: SettingsProps }) => {
|
||||
return (
|
||||
<footer className="rounded-lg py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between">
|
||||
<Link className="mb-4 sm:mb-0 flex justify-center sm:justify-start"
|
||||
href="/">
|
||||
<img src="/images/logo.png" width={170} height={170} alt="logo" />
|
||||
</Link>
|
||||
<div className="text-center mb-4 sm:mb-0">
|
||||
<span className="text-xs text-black">
|
||||
Copyright @ 2024 <a href="https://flowbite.com/" className="hover:underline">All In One</a> All Rights Reserved.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-4 mt-7 lg:mt-0 md:mt-0">
|
||||
<div className="bg-[#DCCECF] rounded-full h-8 w-8 flex items-center justify-center hover:bg-mainColor"
|
||||
onClick={() => window.open(settings.facebook)}>
|
||||
<FaFacebookF className="w-4 h-4 cursor-pointer text-[#F6E5E9]" />
|
||||
</div>
|
||||
<div className="bg-[#DCCECF] rounded-full h-8 w-8 flex items-center justify-center hover:bg-mainColor"
|
||||
onClick={() => window.open(settings.instagram)}>
|
||||
<RiInstagramFill className="w-4 h-4 cursor-pointer text-[#F6E5E9]" />
|
||||
</div>
|
||||
<div className="bg-[#DCCECF] rounded-full h-8 w-8 flex items-center justify-center hover:bg-mainColor"
|
||||
onClick={() => window.open(settings.youtube)}>
|
||||
<FaYoutube className="w-4 h-4 cursor-pointer text-[#F6E5E9]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
124
components/Hero1.tsx
Normal file
124
components/Hero1.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import "animate.css/animate.compat.css"
|
||||
import ScrollAnimation from 'react-animate-on-scroll';
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import TextGradientScroll from './TextGradientScrollContext';
|
||||
import Image from 'next/image';
|
||||
import CustomButton from "./CustomButton";
|
||||
|
||||
const Hero1 = () => {
|
||||
const [paragraphOpacities, setParagraphOpacities] = useState<number[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (scrollRef.current) {
|
||||
const container = scrollRef.current;
|
||||
const isAtBottom = Math.abs(container.scrollHeight - container.scrollTop - container.clientHeight) < 5;
|
||||
|
||||
|
||||
if (isAtBottom) {
|
||||
// Set all paragraphs to full opacity when at the bottom
|
||||
setParagraphOpacities(new Array(11).fill(1));
|
||||
} else {
|
||||
const paragraphs = container.querySelectorAll('p');
|
||||
const newOpacities = Array.from(paragraphs).map(p => {
|
||||
const rect = p.getBoundingClientRect();
|
||||
const yPosition = rect.top - container.getBoundingClientRect().top;
|
||||
return yPosition > 30 ? 0 : 1;
|
||||
});
|
||||
setParagraphOpacities(newOpacities);
|
||||
}
|
||||
}
|
||||
};
|
||||
setParagraphOpacities(new Array(9).fill(0));
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.addEventListener('scroll', handleScroll);
|
||||
setTimeout(handleScroll, 100);
|
||||
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<div className=" relative flex flex-col items-center bg-gradient-to-b from-[#F4D7D7] to-[#e2b2c4] max-sm:h-[94vh] max-lg:h-[170] md:h-[170vh] z-[10] w-full transition-all duration-200 " >
|
||||
{/* Existing content */}
|
||||
|
||||
<div >
|
||||
<div className="flex flex-row items-center justify-center mt-[30vh]">
|
||||
<div className='max-lg:text-6xl max-sm:text-3xl md:text-6xl'>
|
||||
發掘你的
|
||||
</div>
|
||||
<div className=" bg-[url('/images/kid.png')] max-lg:h-[60px] max-lg:w-[140px] max-sm:h-[40px] max-sm:w-[95px] md:h-[60px] md:w-[140px] bg-cover bg-no-repeat rounded-full " />
|
||||
<div className='max-lg:text-6xl max-sm:text-3xl md:text-6xl'>
|
||||
音樂之路
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-lg: w-[61vh] h-[15vh] max-sm:w-[40vh] mt-[5vh] items-center">
|
||||
<div className="flex-grow h-[10vh] overflow-y-auto items-center no-scrollbar" snap-mandatory snap-y ref={scrollRef}>
|
||||
{[
|
||||
"學音樂能陶冶性情,專注,舒緩壓力,放鬆心情。實際上是否做到呢?",
|
||||
"我們認為音樂不單單是「睇五線譜」",
|
||||
"我們可以透過不同方式",
|
||||
"例如聆聽,唱歌,身體律動等學習音樂,令學習不再是沉悶艱難",
|
||||
"真正做到「陶冶性情」",
|
||||
"One & ALL Music 提供全面的音樂課程",
|
||||
"我們相信每位學生都各有天賦",
|
||||
"「誰都可發光 只要找對地方」",
|
||||
"只要運用合適的教學法,同學必定能發揮所長",
|
||||
" ",
|
||||
" ",
|
||||
].map((text, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`text-black max-lg:text-2xl max-sm:text-lg md:text-2xl text-center ${paragraphOpacities[index] === 0 ? 'opacity-25' : ''}`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-[7vh] max-sm:hidden max-lg:block md:block">
|
||||
<div className="flex flex-row items-center justify-center mt-[30vh] ">
|
||||
<div className='max-lg:text-6xl max-sm:text-4xl md:text-6xl'>
|
||||
我們認為
|
||||
</div>
|
||||
<div className=" bg-[url('/images/piano2.png')] max-lg:h-[60px] max-lg:w-[140px] max-sm:h-[40px] max-sm:w-[95px] md:h-[60px] md:w-[140px] bg-cover bg-no-repeat rounded-full " />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[61vh] h-[19vh] mt-[5vh] items-center max-sm:hidden max-lg:block md:block">
|
||||
<p
|
||||
className={`text-black text-2xl text-center `}
|
||||
>
|
||||
{"音樂不單單是「睇五線譜」,我們可以透過不同方式,例如聆聽唱歌,身體律動等學習音樂,令學習不再是沉悶艱難,真正做到「陶治性情」。"}
|
||||
</p>
|
||||
<p
|
||||
className={`text-black text-2xl text-center mt-[5vh]`}
|
||||
>
|
||||
One & ALL Music 提供全面的音樂課程,我們相信每位學生都各有天赋,<br></br>「誰都可發光 只要找對地方」,只要運用合適的教學法,同學必定能發揮所長。
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-lg:mt-[20vh] md:mt-[20vh] max-sm:mt-[5vh]">
|
||||
<CustomButton text_and_button_size={" max-lg:px-14 max-lg:py-5 max-lg:text-md max-sm:px-8 max-sm:py-3 max-sm:text-sm md:px-14 md:py-5 md:text-md"} title={'關於我們更多'} handleClick={() => {
|
||||
console.log('Button clicked');
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 bg-[url('/images/pianobackground2.png')] max-lg:h-[30vh] max-sm:h-[15vh] md:h-[30vh] w-full bg-cover bg-center bg-no-repeat" style={{ opacity: 0.7 }}>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div >
|
||||
);
|
||||
}
|
||||
export default Hero1
|
33
components/Hero2.tsx
Normal file
33
components/Hero2.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
import CustomButton from "./CustomButton";
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { CoursesProps, SettingsProps } from "@/types";
|
||||
|
||||
|
||||
function convertToEmbedLink(watchLink: string): string {
|
||||
const videoIdMatch = watchLink.match(/(?:v=|\/)([a-zA-Z0-9_-]{11})/);
|
||||
if (videoIdMatch && videoIdMatch[1]) {
|
||||
const videoId = videoIdMatch[1];
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
return watchLink; // Return original link if conversion fails
|
||||
}
|
||||
|
||||
const Hero2 = ({ settings }: { settings: SettingsProps }) => {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center bg-[#F6E8E9] h-auto z-[10] w-full pt-30 transition-all duration-200">
|
||||
<div className="flex w-full h-auto justify-center items-center relative">
|
||||
<div className="bg-[url('/images/014.png')] absolute bottom-0 inset-0 h-auto w-full bg-contain bg-bottom bg-no-repeat"></div>
|
||||
<iframe
|
||||
className="rounded-2xl w-[100vh] aspect-video justify-center relative z-10 lg:m-[15vh] max-sm:m-[5vh] md:m-[10vh]"
|
||||
src={convertToEmbedLink(settings.youtube_link)}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Hero2
|
53
components/Home/Home.tsx
Normal file
53
components/Home/Home.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, lazy, Suspense } from "react";
|
||||
import { Audio } from "react-loader-spinner";
|
||||
const Hero1 = lazy(() => import('../Hero1'));
|
||||
import Hero2 from '../Hero2'
|
||||
import CoursesSilder from "../CoursesSilder";
|
||||
import ContactForm from '../ContactForm'
|
||||
import Loading from '../Loading'
|
||||
import Footer from '../Footer'
|
||||
import { fetchCourses } from '../../utils/index'
|
||||
import { CoursesArrayProps, CoursesProps, SettingsProps } from "@/types";
|
||||
import Whatsapp from "../Whatsapp";
|
||||
const Home = ({ courses, settings }: { courses: CoursesProps[], settings: SettingsProps }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
// const [courses, setCourses] = useState<CoursesProps[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCourses = async () => {
|
||||
setLoading(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 300)); // 0.5 second delay
|
||||
setLoading(false);
|
||||
}; loadCourses();
|
||||
}, []);
|
||||
|
||||
const startLoading = () => setLoading(true);
|
||||
const stopLoading = () => setLoading(false);
|
||||
|
||||
console.log(courses);
|
||||
return (
|
||||
<div className='bg-[#F2D5D5]'>
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Loading />
|
||||
</Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Hero1 />
|
||||
<Hero2 settings={settings} />
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<CoursesSilder courses={courses} />
|
||||
</Suspense>
|
||||
<ContactForm startLoading={startLoading} stopLoading={stopLoading} />
|
||||
<Whatsapp settings={settings} />
|
||||
<Footer settings={settings} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Home
|
17
components/Loading.tsx
Normal file
17
components/Loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-5 min-w-screen">
|
||||
|
||||
<div className="flex space-x-2 animate-pulse">
|
||||
<div className="w-3 h-3 bg-[#D60050] rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-[#D60050] rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-[#D60050] rounded-full"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Loading
|
85
components/Navbar/MobileNav.tsx
Normal file
85
components/Navbar/MobileNav.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { FiChevronDown, FiChevronUp } from 'react-icons/fi';
|
||||
import { CoursesProps } from '@/types'
|
||||
//define props type
|
||||
type Props = {
|
||||
showNav: boolean;
|
||||
closeNav: () => void
|
||||
courses: CoursesProps[]
|
||||
}
|
||||
|
||||
const MobileNav = ({ closeNav, showNav, courses }: Props) => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (showNav) {
|
||||
document.body.classList.add('overflow-hidden');
|
||||
} else {
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
};
|
||||
}, [showNav]);
|
||||
|
||||
const navOpen = showNav ? 'translate-x-0' : 'translate-x-[-100%]'
|
||||
const navClose = !showNav ? 'translate-x-[-100%]' : 'translate-x-0'
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/*overlay*/}
|
||||
<div className={`fixed ${navOpen} ${navClose} transform transition-all duration-500 top-[7vh] inset-x-0 bottom-0 z-[1000]`}>
|
||||
|
||||
{/* nav links*/}
|
||||
<div className={`text-white ${navOpen} fixed flex flex-col h-full w-[80%] sm:w-[60%] bg-[#F9E7E9] z-[10000] `}>
|
||||
|
||||
<div className='w-full h-16 flex justify-start items-center border-b-[1.5px] border-[#F5DADF]'>
|
||||
<Link key={"01"} href={"#"} >
|
||||
<p className="text-xl ml-6 text-black">
|
||||
主頁
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={`w-full flex flex-col ${dropdownOpen ? "border-b-[0px]" : "border-b-[1.5px]"} border-[#F5DADF]`}>
|
||||
<div
|
||||
className='h-16 flex justify-between items-center px-6 cursor-pointer'
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
<p className="text-xl text-black">課程</p>
|
||||
{dropdownOpen ? <FiChevronUp className='text-black' size={30}/> : <FiChevronDown className='text-black' size={30}/>}
|
||||
</div>
|
||||
{dropdownOpen && (
|
||||
<div>
|
||||
{courses?.map((course) => (
|
||||
<div className='bg-[#F5DADF] w-full h-16 flex justify-start items-center border-b-[1.5px] border-[#F9E7E9]'>
|
||||
<Link key={course.id} href={`/courses/${course.id}`}>
|
||||
<p className="text-lg text-black ml-8 ">
|
||||
{course.title}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='w-full h-16 flex justify-start items-center border-b-[1.5px] border-[#F5DADF]'>
|
||||
<Link key={"03"} href={"#"} >
|
||||
<p className="text-xl ml-6 text-black">
|
||||
關於我們
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNav
|
||||
|
134
components/Navbar/Nav.tsx
Normal file
134
components/Navbar/Nav.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { FiChevronDown } from "react-icons/fi";
|
||||
import { HiBars3BottomRight } from 'react-icons/hi2'
|
||||
import { FaFacebookF, FaYoutube } from "react-icons/fa";
|
||||
import { RiInstagramFill } from "react-icons/ri";
|
||||
import { IoMenu, IoClose } from "react-icons/io5";
|
||||
import { CoursesProps, SettingsProps } from '@/types'
|
||||
import { colors } from '@/public/themes'
|
||||
//define props type
|
||||
type Props = {
|
||||
openNav: () => void
|
||||
showNav: boolean
|
||||
courses: CoursesProps[]
|
||||
settings: SettingsProps
|
||||
}
|
||||
const Nav = ({ openNav, courses, showNav, settings }: Props) => {
|
||||
const [navBg, setNavBg] = useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (window.scrollY >= 90) {
|
||||
setNavBg(true)
|
||||
} else if (window.scrollY <= 90) {
|
||||
setNavBg(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handler)
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handler)
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`bg-[#F6E5E9] max-sm:bg-[#FFF9F9] h-[7vh] z-[10] w-full transition-all duration-200 `}>
|
||||
<div className="flex items-center justify-between w-[80%] sm:w-[80%] x1:w-[80%] mx-auto">
|
||||
<div className='flex items-center space-x-10'>
|
||||
<Link href={"/"}>
|
||||
<Image
|
||||
|
||||
src="/images/logo.png"
|
||||
alt='logo'
|
||||
width={170}
|
||||
height={170}
|
||||
className="m1-[-1.5rem] sm:m1-0 mr-4"
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex items-center space-x-15">
|
||||
<div className='hidden lg:flex items-center space-x-8'>
|
||||
<Link key={"01"} href={"/"} >
|
||||
<p className="nav__link">
|
||||
{"主頁"}
|
||||
</p>
|
||||
</Link>
|
||||
<div key={"02"} className="relative">
|
||||
<button
|
||||
className="nav__link flex items-center"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
{"課程"}
|
||||
<FiChevronDown className='text-gray-400' />
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute left-0 top-full mt-1 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50">
|
||||
<div className="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
|
||||
{courses.map((course) => (
|
||||
<a
|
||||
key={course.id}
|
||||
href={`/courses/${course.id}`}
|
||||
className={`block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-mainColor`}
|
||||
role="menuitem"
|
||||
>
|
||||
{course.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link key={"03"} href={"#"} >
|
||||
<p className="nav__link">
|
||||
{"關於我們"}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/*button*/}
|
||||
<div className="flex items-center space-x-5 ">
|
||||
<div className="flex items-center space-x-2 max-sm:hidden">
|
||||
<div className="bg-[#DCCECF] rounded-full h-8 w-8 flex items-center justify-center hover:bg-mainColor"
|
||||
onClick={() => window.open(settings.facebook)} >
|
||||
<FaFacebookF
|
||||
className="w-4 h-4 cursor-pointer text-[#F6E5E9]"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-[#DCCECF] rounded-full h-8 w-8 flex items-center justify-center hover:bg-mainColor"
|
||||
onClick={() => window.open(settings.instagram)} >
|
||||
<RiInstagramFill
|
||||
className="w-4 h-4 cursor-pointer text-[#F6E5E9]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#DCCECF] rounded-full h-8 w-8 flex items-center justify-center hover:bg-mainColor"
|
||||
onClick={() => window.open(settings.youtube)} >
|
||||
<FaYoutube
|
||||
className="w-4 h-4 cursor-pointer text-[#F6E5E9]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/*burger*/}
|
||||
{showNav ? (
|
||||
<IoClose
|
||||
onClick={openNav}
|
||||
className="w-8 h-8 cursor-pointer text-mainColor lg:hidden ml-2"
|
||||
/>
|
||||
) : (
|
||||
<IoMenu
|
||||
id='menubutton'
|
||||
onClick={openNav}
|
||||
className="w-8 h-8 cursor-pointer text-mainColor lg:hidden ml-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Nav
|
20
components/Navbar/ResponsiveNav.tsx
Normal file
20
components/Navbar/ResponsiveNav.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
import React, {useState} from 'react'
|
||||
import MobileNav from './MobileNav'
|
||||
import Nav from './Nav'
|
||||
import { CoursesProps,SettingsProps } from '@/types'
|
||||
|
||||
const ResponsiveNav = ({ courses, settings }: { courses: CoursesProps[], settings: SettingsProps }) => {
|
||||
const [showNav, setShowNav] = useState(false)
|
||||
const toggleNavHandler = () => setShowNav(!showNav)
|
||||
const showNavHandler = () => setShowNav(true)
|
||||
const closeNavHandler = () => setShowNav(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Nav openNav={toggleNavHandler} showNav={showNav} courses={courses} settings={settings} />
|
||||
<MobileNav showNav={showNav} closeNav={closeNavHandler} courses={courses} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ResponsiveNav
|
143
components/TextGradientScrollContext.tsx
Normal file
143
components/TextGradientScrollContext.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useRef } from "react";
|
||||
import { useScroll, useTransform, motion, MotionValue } from "framer-motion";
|
||||
import { cn } from "./utils";
|
||||
|
||||
type TextOpacityEnum = "none" | "soft" | "medium";
|
||||
type ViewTypeEnum = "word" | "letter";
|
||||
|
||||
type TextGradientScrollType = {
|
||||
text: string;
|
||||
type?: ViewTypeEnum;
|
||||
className?: string;
|
||||
textOpacity?: TextOpacityEnum;
|
||||
};
|
||||
|
||||
type LetterType = {
|
||||
children: React.ReactNode | string;
|
||||
progress: MotionValue<number>;
|
||||
range: number[];
|
||||
};
|
||||
|
||||
type WordType = {
|
||||
children: React.ReactNode;
|
||||
progress: MotionValue<number>;
|
||||
range: number[];
|
||||
};
|
||||
|
||||
type CharType = {
|
||||
children: React.ReactNode;
|
||||
progress: MotionValue<number>;
|
||||
range: number[];
|
||||
};
|
||||
|
||||
type TextGradientScrollContextType = {
|
||||
textOpacity?: TextOpacityEnum;
|
||||
type?: ViewTypeEnum;
|
||||
};
|
||||
|
||||
const TextGradientScrollContext = createContext<TextGradientScrollContextType>(
|
||||
{}
|
||||
);
|
||||
|
||||
function useGradientScroll() {
|
||||
const context = useContext(TextGradientScrollContext);
|
||||
return context;
|
||||
}
|
||||
|
||||
export default function TextGradientScroll({
|
||||
text,
|
||||
className,
|
||||
type = "letter",
|
||||
textOpacity = "soft",
|
||||
}: TextGradientScrollType) {
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start center", "end center"],
|
||||
});
|
||||
|
||||
const words = text.split(" ");
|
||||
|
||||
return (
|
||||
<TextGradientScrollContext.Provider value={{ textOpacity, type }}>
|
||||
<p ref={ref} className={cn("relative flex m-0 flex-wrap", className)}>
|
||||
{words.map((word, i) => {
|
||||
const start = i / words.length;
|
||||
const end = start + 1 / words.length;
|
||||
return type === "word" ? (
|
||||
<Word key={i} progress={scrollYProgress} range={[start, end]}>
|
||||
{word}
|
||||
</Word>
|
||||
) : (
|
||||
<Letter key={i} progress={scrollYProgress} range={[start, end]}>
|
||||
{word}
|
||||
</Letter>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
</TextGradientScrollContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const Word = ({ children, progress, range }: WordType) => {
|
||||
const opacity = useTransform(progress, range, [0, 1]);
|
||||
|
||||
return (
|
||||
<span className="relative me-2 mt-2">
|
||||
<span style={{ position: "absolute", opacity: 0.1 }}>{children}</span>
|
||||
<motion.span style={{ transition: "all .5s", opacity: opacity }}>
|
||||
{children}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Letter = ({ children, progress, range }: LetterType) => {
|
||||
if (typeof children === "string") {
|
||||
const amount = range[1] - range[0];
|
||||
const step = amount / children.length;
|
||||
|
||||
return (
|
||||
<span className="relative me-2 mt-2">
|
||||
{children.split("").map((char: string, i: number) => {
|
||||
const start = range[0] + i * step;
|
||||
const end = range[0] + (i + 1) * step;
|
||||
return (
|
||||
<Char key={`c_${i}`} progress={progress} range={[start, end]}>
|
||||
{char}
|
||||
</Char>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const Char = ({ children, progress, range }: CharType) => {
|
||||
const opacity = useTransform(progress, range, [0, 1]);
|
||||
const { textOpacity } = useGradientScroll();
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span
|
||||
className={cn("absolute", {
|
||||
"opacity-0": textOpacity == "none",
|
||||
"opacity-10": textOpacity == "soft",
|
||||
"opacity-30": textOpacity == "medium",
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<motion.span
|
||||
style={{
|
||||
transition: "all .5s",
|
||||
opacity: opacity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
};
|
18
components/Whatsapp.tsx
Normal file
18
components/Whatsapp.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { IoLogoWhatsapp } from "react-icons/io";
|
||||
import { SettingsProps } from '@/types';
|
||||
const Whatsapp = ({ settings }: { settings: SettingsProps }) => {
|
||||
return (
|
||||
<div className=" relative">
|
||||
<button className=" z-20 text-white flex flex-col shrink-0 grow-0 justify-around
|
||||
fixed bottom-0 right-0 right-5 rounded-lg
|
||||
mr-1 mb-5 lg:mr-5 lg:mb-5 xl:mr-10 xl:mb-10"
|
||||
onClick={()=>window.open(`https://wa.me/${settings.whatsapp}`)}>
|
||||
<IoLogoWhatsapp size={50} className='text-[#D60050]'/>
|
||||
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Whatsapp
|
30
components/slick.css
Normal file
30
components/slick.css
Normal file
@@ -0,0 +1,30 @@
|
||||
.dots_custom {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: auto 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dots_custom li {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dots_custom li button {
|
||||
border: none;
|
||||
background: #d1d1d1;
|
||||
color: transparent;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
border-radius: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dots_custom li.slick-active button {
|
||||
background-color: #D60050;
|
||||
}
|
6
components/utils.ts
Normal file
6
components/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
Reference in New Issue
Block a user