first commit

This commit is contained in:
2024-10-05 15:04:17 +08:00
parent 792a3d9db1
commit ee746cd61d
44 changed files with 2399 additions and 124 deletions

112
components/ContactForm.tsx Normal file
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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;
}

View 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;

View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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

View 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

View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}